初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user