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