Files
oci-portal-dash/src/components/task/TaskFormModal.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

347 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NInput, NSelect } from 'naive-ui'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { listCachedCompartments, listCachedRegions, listConfigs } from '@/api/configs'
import { createTask, updateTask } from '@/api/tasks'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FormSection from '@/components/FormSection.vue'
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
import CronEditor from '@/components/task/CronEditor.vue'
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { defaultResourceName } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { Task, TaskType } from '@/types/api'
import { useToast } from '@/composables/useToast'
/** task 传入即为编辑模式:回填全部字段,任务类型锁定不可切换 */
const props = defineProps<{ show: boolean; task?: Task | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const isEdit = computed(() => !!props.task)
/** 任务类型选择卡:新建时三选一,编辑时锁定当前类型 */
const TYPE_CARDS: { value: TaskType; title: string; desc: string }[] = [
{ value: 'health_check', title: '定时测活', desc: '周期性验证租户凭据,失效推送通知' },
{ value: 'cost', title: '每日成本同步', desc: '拉取 Usage 账单汇总到总览' },
{ value: 'snatch', title: '抢机', desc: '容量不足时反复尝试创建,抢满自停' },
]
const message = useToast()
const submitting = ref(false)
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
const blank = {
type: 'health_check' as TaskType,
name: '',
cronExpr: '',
checkCfgIds: [] as number[],
snatchCfgId: null as number | null,
snatchRegion: '',
snatchCompartmentId: '',
count: 1,
displayName: '',
}
const form = reactive({ ...blank })
/** 编辑回填期间抑制 snatchCfgId watch 的级联清空 */
let applying = false
watch(
() => props.show,
async (v) => {
if (!v) return
if (!props.task) {
Object.assign(form, blank, { checkCfgIds: [], displayName: defaultResourceName('snatch') })
return
}
const t = props.task
const p = JSON.parse(t.payload || '{}') as Record<string, unknown>
applying = true
Object.assign(form, blank, {
type: t.type,
name: t.name,
cronExpr: t.cronExpr,
checkCfgIds: Array.isArray(p.ociConfigIds) ? p.ociConfigIds : [],
})
if (t.type === 'snatch') {
const inst = (p.instance ?? {}) as Record<string, unknown>
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
form.count = typeof p.count === 'number' ? p.count : 1
form.displayName = String(inst.displayName ?? '')
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
await nextTick() // 等 snatch tab 与 SpecForm 挂载
await nextTick()
specForm.value?.applySpec(inst)
}
await nextTick()
applying = false
},
)
const configs = useAsync(listConfigs)
const typeLabel = computed(
() => TYPE_CARDS.find((c) => c.value === form.type)?.title ?? form.type,
)
/** 类型卡点击:编辑态锁定;切换时保持已填的名称与计划 */
function pickType(t: TaskType) {
if (isEdit.value || form.type === t) return
form.type = t
}
const snatchCfg = computed(
() => configs.data.value?.find((c) => c.id === form.snatchCfgId) ?? null,
)
/** 抢机目标租户的区域 / 区间数据源(与全局作用域选择器同一套缓存接口) */
const regions = useAsync(
() =>
form.snatchCfgId === null
? Promise.resolve([])
: listCachedRegions(form.snatchCfgId).catch(() => []),
false,
)
const compartments = useAsync(
() =>
form.snatchCfgId === null
? Promise.resolve([])
: listCachedCompartments(form.snatchCfgId).catch(() => []),
false,
)
watch(
() => form.snatchCfgId,
() => {
if (!applying) {
form.snatchRegion = ''
form.snatchCompartmentId = ''
}
void regions.run()
void compartments.run()
},
)
// 区域加载后默认主区域;未开启多区域的租户锁定唯一默认区域
watch(
() => regions.data.value,
(subs) => {
if (!subs?.length) return
if (!form.snatchRegion || !subs.some((s) => s.name === form.snatchRegion))
form.snatchRegion = subs.find((s) => s.isHomeRegion)?.name ?? subs[0].name
},
)
const regionDisabled = computed(() => !snatchCfg.value?.multiRegion)
const compDisabled = computed(() => !snatchCfg.value?.multiCompartment)
const regionOptions = computed(() =>
(regions.data.value ?? []).map((s) => ({
label: s.isHomeRegion ? `${s.alias} · 主` : s.alias,
value: s.name,
})),
)
const compOptions = computed(() => [
{ label: `${snatchCfg.value?.tenancyName || '租户'}(根)`, value: '' },
...(compartments.data.value ?? []).map((c) => ({ label: c.name, value: c.id })),
])
const canSubmit = computed(() => {
if (!form.name.trim() || !form.cronExpr.trim()) return false
if (form.type !== 'snatch') return true
if (form.snatchCfgId === null || !form.displayName.trim()) return false
return specForm.value?.valid ?? false
})
function buildPayload(): Record<string, unknown> {
if (form.type !== 'snatch') return { ociConfigIds: form.checkCfgIds }
return {
ociConfigId: form.snatchCfgId,
count: form.count,
instance: {
displayName: form.displayName.trim(),
region: form.snatchRegion || undefined,
compartmentId: form.snatchCompartmentId || undefined,
...(specForm.value?.buildSpec() ?? {}),
},
}
}
async function submit() {
submitting.value = true
try {
if (props.task) {
await updateTask(props.task.id, {
name: form.name.trim(),
cronExpr: form.cronExpr.trim(),
payload: buildPayload(),
})
message.success(`任务「${form.name.trim()}」已更新`)
} else {
const created = await createTask({
name: form.name.trim(),
type: form.type,
cronExpr: form.cronExpr.trim(),
payload: buildPayload(),
})
message.success(`任务「${created.name}」已创建并进入调度`)
}
emit('update:show', false)
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : isEdit.value ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<FormModal
:show="show"
:title="isEdit ? '编辑后台任务' : '新建后台任务'"
:width="600"
:submitting="submitting"
:submit-disabled="!canSubmit"
:submit-text="isEdit ? '保存' : '创建并调度'"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<template v-if="isEdit" #header>
<div class="flex items-center gap-2.5">
<span>编辑后台任务</span>
<span
class="flex items-center gap-1 rounded-full border border-line bg-wash px-2.5 py-0.5 text-[11px] font-medium text-ink-2"
>
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{{ typeLabel }} · 类型不可改
</span>
</div>
</template>
<FormField v-if="!isEdit" label="任务类型" required>
<div class="grid grid-cols-3 gap-2.5 max-md:grid-cols-1">
<button
v-for="c in TYPE_CARDS"
:key="c.value"
type="button"
class="cursor-pointer rounded-lg border bg-white p-3 text-left"
:class="
form.type === c.value
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="pickType(c.value)"
>
<span
class="mb-2 flex h-7 w-7 items-center justify-center rounded-md"
:class="form.type === c.value ? 'bg-accent/12 text-accent' : 'bg-wash text-ink-2'"
>
<TaskTypeIcon :type="c.value" class="h-3.5 w-3.5" />
</span>
<div class="text-[12.5px] font-semibold">{{ c.title }}</div>
<div class="mt-0.5 text-[11px] leading-snug text-ink-3">{{ c.desc }}</div>
</button>
</div>
</FormField>
<FormField label="任务名" required>
<NInput v-model:value="form.name" placeholder="如「每日全量测活」" />
</FormField>
<FormField
label="执行计划"
required
hint="按服务器本地时区调度;选「自定义 cron」可直接书写 5 段表达式"
>
<CronEditor v-model:value="form.cronExpr" />
</FormField>
<template v-if="form.type === 'health_check'">
<FormField label="测活范围" hint="留空表示全部租户;搜索同时匹配别名与租户名称">
<TenantPicker
v-model:values="form.checkCfgIds"
multiple
clearable
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
placeholder="全部租户"
/>
</FormField>
</template>
<template v-else-if="form.type === 'cost'">
<FormField
label="同步范围"
hint="留空表示全部租户;免费类别租户执行时自动跳过,不发起 Usage API 请求"
>
<TenantPicker
v-model:values="form.checkCfgIds"
multiple
clearable
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
placeholder="全部租户"
/>
</FormField>
</template>
<template v-else>
<FormSection title="目标" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="目标租户" required>
<TenantPicker
v-model:value="form.snatchCfgId"
:configs="configs.data.value ?? []"
:loading="configs.loading.value"
/>
</FormField>
<FormField label="目标台数" hint="每次调度按剩余台数尝试,抢满后任务自动停止">
<AppInputNumber v-model:value="form.count" :min="1" :max="10" class="w-full" />
</FormField>
</div>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="区域" hint="未开启多区域的租户锁定默认区域">
<NSelect
v-model:value="form.snatchRegion"
:options="regionOptions"
:loading="regions.loading.value"
:disabled="regionDisabled"
:consistent-menu-width="false"
/>
</FormField>
<FormField label="区间" hint="未开启多区间的租户锁定根 compartment">
<NSelect
v-model:value="form.snatchCompartmentId"
:options="compOptions"
:loading="compartments.loading.value"
:disabled="compDisabled"
/>
</FormField>
</div>
<FormSection title="实例规格(与创建实例一致)" />
<FormField label="实例名" required>
<NInput v-model:value="form.displayName" placeholder="my-a1" />
</FormField>
<InstanceSpecForm
ref="specForm"
:cfg-id="form.snatchCfgId"
:region="form.snatchRegion || undefined"
:compartment-id="form.snatchCompartmentId || undefined"
ad-hint="留空 = 自动轮询区域全部可用域,每次执行换下一个(ad-1 ad-2 循环)"
/>
<div class="mt-2 text-xs leading-relaxed text-ink-3">
VCN 自动创建时复用或创建oci-portal-auto-vcn 公网子网 ·
每次调度按剩余台数尝试创建容量不足自动等待下次执行
</div>
</template>
</FormModal>
</template>