347 lines
12 KiB
Vue
347 lines
12 KiB
Vue
<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>
|