初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+345
View File
@@ -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>