初始提交: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
+323
View File
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { NButton, NSpin, useDialog, useMessage } from 'naive-ui'
import { computed, onUnmounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
import EmptyCard from '@/components/EmptyCard.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TaskFormModal from '@/components/task/TaskFormModal.vue'
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
import { fmtRelative, fmtTime, regionCity } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useScopeStore } from '@/stores/scope'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const scope = useScopeStore()
const taskId = computed(() => Number(route.params.taskId))
const task = useAsync(() => getTask(taskId.value))
const logs = useAsync(() => listTaskLogs(taskId.value))
// 每 5 秒静默刷新任务与事件流
const refreshTimer = setInterval(() => {
void task.run({ silent: true })
void logs.run({ silent: true })
}, 5000)
onUnmounted(() => clearInterval(refreshTimer))
const snatch = computed(() => (task.data.value ? parseSnatch(task.data.value) : null))
const status = computed(() => (task.data.value ? STATUS_META[task.data.value.status] : null))
const cfgAlias = computed(() => {
const s = snatch.value
if (!s) return ''
return (scope.configs.data ?? []).find((c) => c.id === s.cfgId)?.alias ?? `租户 #${s.cfgId}`
})
const pct = computed(() => {
const s = snatch.value
if (!s?.total) return 0
return Math.min(100, Math.round((s.done / s.total) * 100))
})
/** 右栏任务参数键值对(按类型组装) */
const params = computed<[string, string][]>(() => {
const t = task.data.value
if (!t) return []
const s = snatch.value
if (s) {
return [
['所属租户', cfgAlias.value],
['目标 Shape', s.shape],
['规格', s.ocpus ? `${s.ocpus} OCPU · ${s.memoryInGBs} GB` : '—'],
['引导卷', s.bootGb ? `${s.bootGb} GB` : '默认'],
['实例名', s.displayName ?? '—'],
['目标数量', `${s.total}`],
['剩余数量', `${s.remaining}`],
['调度周期', t.cronExpr],
['区域', s.region ? regionCity(s.region) : '默认'],
]
}
const ids = scopeCfgIds(t)
const scope = t.type === 'ai_probe'
? '号池渠道对应租户'
: ids.length ? `${ids.length} 个租户` : '全部租户'
return [
['类型', TYPE_LABEL[t.type]],
['范围', scope],
['调度周期', t.cronExpr],
['已运行', `${t.runCount}`],
]
})
const showEdit = ref(false)
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void task.run({ silent: true })
void logs.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
/** active ↔ paused 切换;failed(熔断停止)同走恢复交互重新启用 */
function toggle() {
const t = task.data.value
if (!t) return
const resume = t.status !== 'active'
void act(
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
)
}
function confirmRemove() {
const t = task.data.value
if (!t) return
dialog.warning({
title: '删除任务',
content: `删除任务「${t.name}」及其全部日志?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteTask(t.id)
message.success('已删除任务及其日志')
void router.push({ name: 'tasks' })
},
})
}
const features = computed(() => {
const base = [
['持久化存储', '任务参数与进度写入数据库,不依赖内存状态'],
['重启自动恢复', '服务重启后自动加载未完成任务并继续调度'],
]
if (task.data.value?.type === 'snatch')
base.push(['抢满自动停止', '达到目标数量后任务自动标记完成,不再调度'])
else base.push(['日志滚动保留', '每个任务保留最近 100 条执行日志'])
return base
})
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<RouterLink
:to="{ name: 'tasks' }"
class="flex w-fit items-center gap-1.5 text-[13px] text-ink-3 transition-colors hover:text-ink"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 6l-6 6 6 6" />
</svg>
返回任务列表
</RouterLink>
<div
v-if="task.loading.value && !task.data.value"
class="panel flex items-center justify-center py-20"
>
<NSpin size="small" />
</div>
<div v-else-if="task.error.value" class="panel px-5 py-10 text-center text-[13px] text-ink-3">
{{ task.error.value }}
</div>
<div
v-else-if="task.data.value"
class="grid grid-cols-[minmax(0,1fr)_360px] items-start gap-4 max-lg:grid-cols-1"
>
<div class="flex min-w-0 flex-col gap-4">
<!-- 头部名称 + 操作 + 进度 -->
<div class="panel px-6 py-5">
<div class="flex flex-wrap items-start gap-3.5">
<div class="flex h-11 w-11 flex-none items-center justify-center rounded-[10px] bg-wash">
<TaskTypeIcon :type="task.data.value.type" class="h-[22px] w-[22px] text-accent" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2.5">
<span class="page-title !text-xl">{{ task.data.value.name }}</span>
<StatusBadge v-if="status" :kind="status.kind" :label="status.label" />
</div>
<div class="mono mt-1 text-xs text-ink-3">
{{ TYPE_LABEL[task.data.value.type] }} · {{ task.data.value.cronExpr }} · 创建于
{{ fmtTime(task.data.value.createdAt) }}
</div>
</div>
<div class="flex flex-none items-center gap-2">
<NButton
size="small"
@click="act(() => runTask(task.data.value!.id), '已触发执行')"
>
立即执行
</NButton>
<NButton
v-if="task.data.value.status !== 'succeeded'"
size="small"
@click="toggle"
>
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
</NButton>
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton>
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</NButton>
</div>
</div>
<div v-if="snatch" class="mt-5">
<div class="mb-2 flex items-center justify-between">
<span class="text-[13px] text-ink-3">创建进度</span>
<span class="mono text-[15px]">
<b
class="text-lg"
:style="{
color:
task.data.value.status === 'succeeded'
? 'var(--color-ok)'
: 'var(--color-accent)',
}"
>{{ snatch.done }}</b>
/ {{ snatch.total }}
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-line-soft">
<div
class="h-full rounded-full transition-all"
:style="{
width: pct + '%',
background:
task.data.value.status === 'succeeded'
? 'var(--color-ok)'
: 'var(--color-accent)',
}"
/>
</div>
<div class="mt-2 text-xs text-ink-3">
已尝试 {{ task.data.value.runCount }} · 上次运行
{{ fmtRelative(task.data.value.lastRunAt) }}
</div>
</div>
<div
v-if="task.data.value.lastError"
class="mt-4 rounded-md border border-err/30 bg-err/10 px-3 py-2 text-[13px] break-all text-err"
>
{{ task.data.value.lastError }}
</div>
</div>
<!-- 事件流 -->
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-5 py-3.5">
<div class="text-sm font-semibold">事件流</div>
<div class="text-xs text-ink-3">最新事件置顶 · 保留近 100 </div>
</div>
<div class="max-h-[560px] overflow-y-auto px-5.5 pt-4 pb-3">
<div
v-for="(log, i) in logs.data.value ?? []"
:key="log.id"
class="relative flex gap-3 pb-5 last:pb-1"
>
<div
v-if="i < (logs.data.value?.length ?? 0) - 1"
class="absolute top-4 bottom-0 left-[5px] w-px bg-line-soft"
/>
<div
class="mt-1 h-[11px] w-[11px] flex-none rounded-full border-[2.5px]"
:style="{
borderColor: log.success ? 'var(--color-ok)' : 'var(--color-err)',
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 22%, var(--color-card))`,
}"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="mono text-[11.5px] text-ink-3">{{ fmtTime(log.createdAt) }}</span>
<span
class="rounded px-1.5 text-[10.5px] font-semibold tracking-wide"
:style="{
color: log.success ? 'var(--color-ok)' : 'var(--color-err)',
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 10%, transparent)`,
}"
>
{{ log.success ? 'SUCCESS' : 'FAILED' }}
</span>
<span class="mono text-[11.5px] text-ink-3">{{ log.durationMs }}ms</span>
</div>
<div class="mono mt-0.5 text-[13px] break-all">{{ log.message }}</div>
</div>
</div>
<EmptyCard
v-if="logs.data.value && !logs.data.value.length"
title="暂无事件"
note="任务执行后事件流会出现在这里"
/>
</div>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col gap-4">
<div class="panel">
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">任务参数</div>
<div class="px-5 pt-1 pb-2.5">
<div
v-for="[k, v] in params"
:key="k"
class="flex items-baseline justify-between gap-3 border-b border-line-soft py-2.5 last:border-b-0"
>
<span class="flex-none text-[13px] text-ink-3">{{ k }}</span>
<span class="mono min-w-0 truncate text-[13px]" :title="v">{{ v }}</span>
</div>
</div>
</div>
<div class="panel">
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">运维特性</div>
<div class="px-5 py-2">
<div v-for="[t, d] in features" :key="t" class="flex gap-2.5 py-2.5">
<svg
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 12.5l5 5L20 6.5" />
</svg>
<div>
<div class="text-[13px] font-semibold">{{ t }}</div>
<div class="mt-0.5 text-xs text-ink-3">{{ d }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<TaskFormModal v-model:show="showEdit" :task="task.data.value" @created="task.run()" />
</div>
</template>