364 lines
12 KiB
Vue
364 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NPopover, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
|
import { computed, h, reactive, ref, watch, 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 { aliveMeta } from '@/composables/useAliveStatus'
|
|
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'
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
const message = useToast()
|
|
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),
|
|
),
|
|
)
|
|
})
|
|
|
|
/** 客户端分页:字面量对象会在重渲染时被重建导致状态丢失,须用 reactive 受控 */
|
|
const pagination = reactive({
|
|
page: 1,
|
|
pageSize: 10,
|
|
showSizePicker: true,
|
|
pageSizes: [10, 20, 50, 100],
|
|
onUpdatePage: (p: number) => {
|
|
pagination.page = p
|
|
},
|
|
onUpdatePageSize: (ps: number) => {
|
|
pagination.pageSize = ps
|
|
pagination.page = 1
|
|
},
|
|
})
|
|
/** 搜索或分组过滤变化后回第 1 页,避免停留在越界页码 */
|
|
watch([keyword, groupFilter], () => {
|
|
pagination.page = 1
|
|
})
|
|
|
|
/** 任务是否作用于该租户:抢机看目标租户;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}」及关联本地数据,OCI 云端资源未受影响`)
|
|
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 meta = aliveMeta(row.aliveStatus)
|
|
const badge = h(StatusBadge, { kind: meta.kind, label: meta.label })
|
|
if (!['dead', 'suspended'].includes(row.aliveStatus) || !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: () =>
|
|
h('div', { class: 'max-w-80 text-[13px] leading-relaxed' }, [
|
|
h('div', { class: 'font-medium' }, `删除「${row.alias}」?`),
|
|
h(
|
|
'div',
|
|
{ class: 'mt-1 text-xs text-ink-3' },
|
|
'将清理面板内关联数据:后台任务、测活 / 成本快照、回传事件、Webhook 密钥与 AI 渠道。',
|
|
),
|
|
h(
|
|
'div',
|
|
{ class: 'mt-0.5 text-xs text-ink-3' },
|
|
'OCI 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。',
|
|
),
|
|
]),
|
|
},
|
|
),
|
|
])
|
|
}
|
|
|
|
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="pagination"
|
|
: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>
|