初始提交: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
+331
View File
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, ref, type VNodeChild } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { listAiChannels } from '@/api/aigateway'
import { deleteConfig, listConfigs, verifyConfig } from '@/api/configs'
import { listTasks } from '@/api/tasks'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import EditConfigModal from '@/components/config/EditConfigModal.vue'
import ImportConfigModal from '@/components/config/ImportConfigModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
import { fmtRelative, fmtTime } from '@/composables/useFormat'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useAsync } from '@/composables/useAsync'
import { useScopeStore } from '@/stores/scope'
import type { AccountType, OciConfigSummary, Task } from '@/types/api'
const message = useMessage()
const router = useRouter()
const { alias: regionAlias } = useRegionAlias()
const scope = useScopeStore()
/** 租户增删改 / 测活后本页列表与全局作用域选择器一起刷新 */
function reloadConfigs() {
void configs.run()
void scope.refreshConfigs()
}
const keyword = ref('')
const UNGROUPED = '__ungrouped__'
const groupFilter = ref<string | null>(null)
const verifying = ref<number | null>(null)
const showImport = ref(false)
const showEdit = ref(false)
const editing = ref<OciConfigSummary | null>(null)
const configs = useAsync(listConfigs)
const tasks = useAsync(listTasks)
/** AI 探测任务的作用面 = 号池渠道对应租户 */
const aiChannels = useAsync(listAiChannels)
const groupOptions = computed(() => {
const list = configs.data.value ?? []
const names = [...new Set(list.map((c) => c.group).filter(Boolean))].sort()
const options = names.map((g) => ({ label: g, value: g }))
if (list.some((c) => !c.group)) options.push({ label: '未分组', value: UNGROUPED })
return options
})
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
let list = configs.data.value ?? []
const g = groupFilter.value
if (g !== null) list = list.filter((c) => (g === UNGROUPED ? !c.group : c.group === g))
if (!kw) return list
return list.filter((c) =>
[c.alias, c.tenancyName, c.region, c.tenancyOcid, c.group].some((v) =>
v.toLowerCase().includes(kw),
),
)
})
/** 任务是否作用于该租户:抢机看目标租户;AI 探测看号池渠道(仅入池租户);
* 测活 / 成本看范围(空 = 全部租户) */
function taskTouchesCfg(task: Task, cfgId: number): boolean {
const s = parseSnatch(task)
if (s) return s.cfgId === cfgId
if (task.type === 'ai_probe')
return (aiChannels.data.value ?? []).some((ch) => ch.ociConfigId === cfgId)
const ids = scopeCfgIds(task)
return !ids.length || ids.includes(cfgId)
}
function tasksFor(cfgId: number): Task[] {
return (tasks.data.value ?? []).filter((t) => taskTouchesCfg(t, cfgId))
}
async function verify(row: OciConfigSummary) {
verifying.value = row.id
try {
const result = await verifyConfig(row.id)
const changed = Object.keys(result.changes).length
message.success(changed ? `测活完成,${changed} 个字段有变更` : '测活完成,无变更')
reloadConfigs()
} catch (e) {
message.error(e instanceof Error ? e.message : '测活失败')
} finally {
verifying.value = null
}
}
function edit(row: OciConfigSummary) {
editing.value = row
showEdit.value = true
}
/** 把该租户与其主区域写入全局作用域选择器(顶栏立即生效) */
function setGlobal(row: OciConfigSummary) {
scope.setGlobalScope(row.id, row.region)
message.success(`全局已切换到「${row.alias}」· ${regionAlias(row.region)}`)
}
async function remove(row: OciConfigSummary) {
try {
await deleteConfig(row.id)
message.success(`已删除「${row.alias}」(仅移除本面板配置,不影响云端资源)`)
reloadConfigs()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
function renderTenant(row: OciConfigSummary): VNodeChild {
return h('div', { class: 'min-w-0' }, [
h(
RouterLink,
{
class: 'font-medium text-ink hover:text-accent',
to: { name: 'tenant-detail', params: { id: row.id } },
},
{ default: () => row.alias },
),
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.tenancyName),
])
}
/** 状态列:仅徽标;失联且有错误详情时 hover 徽标以 tooltip 展示完整 lastError */
function renderStatus(row: OciConfigSummary): VNodeChild {
const badge = h(StatusBadge, {
kind: row.aliveStatus === 'alive' ? 'run' : row.aliveStatus === 'dead' ? 'term' : 'check',
label: row.aliveStatus === 'alive' ? '存活' : row.aliveStatus === 'dead' ? '失联' : '未测',
})
if (row.aliveStatus !== 'dead' || !row.lastError) return badge
return h(
NTooltip,
{ trigger: 'hover', placement: 'top-start' },
{
trigger: () => badge,
default: () => h('div', { class: 'max-w-80 text-xs' }, row.lastError),
},
)
}
/** 「后台任务」列:数量 + 悬停任务清单(点击任务名进详情) */
function renderTasks(row: OciConfigSummary): VNodeChild {
const list = tasksFor(row.id)
if (!list.length) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
return h(
NPopover,
{ trigger: 'hover', placement: 'bottom-start' },
{
trigger: () =>
h(
'span',
{
class:
'mono cursor-pointer border-b border-dashed border-ink-3/60 text-[13px] font-medium hover:text-accent',
onClick: () => router.push({ name: 'tasks' }),
},
String(list.length),
),
default: () => renderTaskPopover(list),
},
)
}
function renderTaskPopover(list: Task[]): VNodeChild {
return h('div', { class: 'flex min-w-52 flex-col gap-1.5' }, [
h('div', { class: 'text-xs text-ink-3' }, '关联后台任务 · 点击查看详情'),
...list.map((t) =>
h('div', { class: 'flex items-center justify-between gap-4' }, [
h(
'button',
{
class: 'cursor-pointer text-left text-[13px] text-ink hover:text-accent',
onClick: () => router.push({ name: 'task-detail', params: { taskId: t.id } }),
},
`${TYPE_LABEL[t.type]} · ${t.name}`,
),
h(StatusBadge, { kind: STATUS_META[t.status].kind, label: STATUS_META[t.status].label }),
]),
),
])
}
/** 代理列:标签示关联态,hover 显示代理名 */
function renderProxy(row: OciConfigSummary): VNodeChild {
if (!row.proxyId) return h('span', { class: 'text-[13px] text-ink-3' }, '直连')
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h(StatusBadge, { kind: 'run', label: '代理', dot: false }),
default: () => row.proxyName || `#${row.proxyId}`,
},
)
}
function renderActions(row: OciConfigSummary): VNodeChild {
return h('div', { class: 'flex items-center gap-1' }, [
h(
NButton,
{
size: 'tiny',
quaternary: true,
disabled: scope.cfgId === row.id && scope.region === row.region,
onClick: () => setGlobal(row),
},
{ default: () => '设为全局' },
),
h(
NButton,
{ size: 'tiny', quaternary: true, loading: verifying.value === row.id, onClick: () => verify(row) },
{ default: () => '测活' },
),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => edit(row) }, { default: () => '修改' }),
h(
NPopconfirm,
{ onPositiveClick: () => remove(row), negativeText: '取消', positiveText: '删除' },
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () => `删除「${row.alias}」?仅移除本面板配置与快照,不影响云端资源。`,
},
),
])
}
const typeOrder: Record<AccountType, number> = { paid: 0, trial: 1, free: 2, unknown: 3 }
const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
{
title: '租户',
key: 'alias',
minWidth: 200,
sorter: (a, b) => a.alias.localeCompare(b.alias, 'zh-CN'),
render: renderTenant,
},
{
title: '分组',
key: 'group',
width: 100,
sorter: (a, b) => a.group.localeCompare(b.group, 'zh-CN'),
render: (row) =>
row.group
? h('span', { class: 'text-[13px]' }, row.group)
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
},
{
title: '主区域',
key: 'region',
width: 195,
sorter: (a, b) => regionAlias(a.region).localeCompare(regionAlias(b.region), 'zh-CN'),
render: (row) =>
h('span', { class: 'text-[13px] whitespace-nowrap' }, regionAlias(row.region)),
},
{
title: '类别',
key: 'accountType',
width: 90,
sorter: (a, b) => typeOrder[a.accountType] - typeOrder[b.accountType],
render: (row) => h(AccountTypeBadge, { type: row.accountType }),
},
{ title: '状态', key: 'aliveStatus', width: 120, render: renderStatus },
{ title: '代理', key: 'proxyId', width: 80, render: renderProxy },
{ title: '后台任务', key: 'tasks', width: 90, render: renderTasks },
{
title: '导入时间',
key: 'createdAt',
width: 110,
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(row.createdAt).slice(0, 10)),
},
{
title: '上次测活',
key: 'lastVerifiedAt',
width: 110,
sorter: (a, b) => (a.lastVerifiedAt ?? '').localeCompare(b.lastVerifiedAt ?? ''),
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, fmtRelative(row.lastVerifiedAt)),
},
{ title: '操作', key: 'actions', width: 226, render: renderActions },
])
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-baseline gap-2.5">
<h1 class="page-title">租户</h1>
<span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span>
</div>
<NButton size="small" type="primary" @click="showImport = true">导入租户</NButton>
</div>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 px-4.5 py-3.5">
<NInputGroup class="!w-56 max-md:!w-full">
<NInputGroupLabel size="small">分组</NInputGroupLabel>
<NSelect
v-model:value="groupFilter"
size="small"
clearable
placeholder="全部"
:options="groupOptions"
:disabled="!groupOptions.length"
/>
</NInputGroup>
<div class="max-w-72 min-w-40 flex-1 max-md:max-w-full">
<NInput
v-model:value="keyword"
size="small"
placeholder="搜索别名 / 租户名 / OCID / 分组"
clearable
/>
</div>
</div>
<NDataTable
:columns="columns"
:data="filtered"
:loading="configs.loading.value"
:pagination="{ pageSize: 10 }"
:scroll-x="1200"
:row-key="(r: OciConfigSummary) => r.id"
/>
</div>
<ImportConfigModal v-model:show="showImport" @imported="reloadConfigs()" />
<EditConfigModal v-model:show="showEdit" :config="editing" @updated="reloadConfigs()" />
</div>
</template>