初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, NSelect, useMessage } 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'
|
||||
|
||||
/** 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 = useMessage()
|
||||
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>
|
||||
Reference in New Issue
Block a user