feat(web): 管理面三页(阶段 7)

- key 管理:清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度 / 停用来源)+ 状态徽章三面孔(运行中 / 已停用 / 已失效 + tooltip);登记弹窗(三类失败文案);停用弹确认、启用直接执行
- 购买记录:清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录共用;期间顺序校验);核对提示(未登记订阅提示条 + 观测折叠区 + 补录预填订阅 ID 与观测额度)
- 换算参数:时间线两列 + 「当前生效」标记 + 页首说明(纯只读)
- 修复:补 n-config-provider 中文 locale(naive-ui 默认英文按钮);两处表格 scroll-x(列宽被压缩致表头截断)
- 走查:Playwright 15 步全操作通过(含三类登记结果、确认弹窗、补录与 CRUD)
This commit is contained in:
2026-09-15 01:06:43 +08:00
parent 0e499eb8e1
commit fca01798e2
6 changed files with 707 additions and 25 deletions
+265 -6
View File
@@ -1,13 +1,272 @@
<script setup lang="ts">
// key 管理页(交互设计 §3.2)——骨架占位:
// 清单 8 列(key 名 / 状态徽章 / 已用 / 剩余 / 到期 / 登记时间 / 数据截止 / 操作)
// 登记:页头「+ 登记 key」→ n-modal;启停:行内按钮(停用弹确认、启用直接执行)
// 数据形状见 docs/design/API契约.md §2.2
// key 管理页(交互设计 §3.2):清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度);
// 登记(n-modal,三类失败文案);启停(停用弹确认、启用直接执行)
import { computed, h, onMounted, ref } from 'vue'
import {
NButton,
NPopconfirm,
NTag,
NTooltip,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import type { ApiKeyRow } from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatCount, formatTime, formatTimeShort } from '@/utils/format'
const message = useMessage()
const rows = ref<ApiKeyRow[]>([])
const loading = ref(false)
const patchingId = ref<number | null>(null)
const showModal = ref(false)
const keyInput = ref('')
const registering = ref(false)
const registerError = ref('')
onMounted(load)
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await api.apiKeysList<ApiKeyRow[]>()
} catch (e) {
message.error(e instanceof ApiError ? e.message : '加载失败')
} finally {
loading.value = false
}
}
/** 状态徽章三面孔:运行中(绿)· 已停用(灰,manual)· 已失效(红,auto_401 + tooltip) */
function renderStatus(row: ApiKeyRow) {
if (row.status === 'active') {
return h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => '运行中' })
}
if (row.disabled_source === 'auto_401') {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () =>
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => '已失效' }),
default: () => '连续 401 自动摘除——请确认 key 是否已被上游禁用',
},
)
}
return h(NTag, { size: 'small', bordered: false }, { default: () => '已停用' })
}
async function patch(row: ApiKeyRow, status: 'active' | 'disabled'): Promise<void> {
patchingId.value = row.id
try {
const updated = await api.apiKeyPatch<ApiKeyRow>(row.id, { status })
rows.value = rows.value.map((r) => (r.id === updated.id ? updated : r))
message.success(status === 'active' ? '已启用' : '已停用')
} catch (e) {
if (e instanceof ApiError) {
if (e.code === 'key_rejected') message.error('启用失败:上游拒绝该 key')
else if (e.code === 'upstream_unavailable') message.error('启用失败:上游暂不可用,请稍后重试')
else if (e.code === 'not_found') message.error('记录不存在,请刷新')
else message.error(e.message)
} else {
message.error('操作失败')
}
} finally {
patchingId.value = null
}
}
function describeRegisterError(e: unknown): string {
if (e instanceof ApiError) {
if (e.code === 'key_rejected') return '上游拒绝该 key(请确认 key 有效、未被上游禁用)'
if (e.code === 'already_registered') return '该 key 已登记'
if (e.code === 'upstream_unavailable') return '上游暂不可用,请稍后重试'
return e.message
}
return '登记失败'
}
async function submitRegister(): Promise<void> {
const key = keyInput.value.trim()
if (!key) {
registerError.value = '请粘贴 sk-key'
return
}
registering.value = true
registerError.value = ''
try {
const created = await api.apiKeyRegister<ApiKeyRow>({ key })
rows.value = [...rows.value, created]
showModal.value = false
keyInput.value = ''
message.success('登记成功,首轮数据同步中…')
} catch (e) {
registerError.value = describeRegisterError(e)
} finally {
registering.value = false
}
}
const columns = computed<DataTableColumns<ApiKeyRow>>(() => [
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
{ title: 'key 名', key: 'token_name', minWidth: 140, ellipsis: { tooltip: true } },
{ title: '状态', key: 'status', width: 96, render: (row) => renderStatus(row) },
{
title: '累计已用',
key: 'total_used',
width: 120,
align: 'right',
render: (row) => formatCount(row.total_used),
},
{
title: '累计剩余',
key: 'total_available',
width: 120,
align: 'right',
render: (row) => (row.unlimited_quota ? '不限' : formatCount(row.total_available)),
},
{
title: '到期',
key: 'expires_at',
width: 176,
render: (row) => (row.expires_at ? formatTime(row.expires_at) : '不过期'),
},
{
title: '登记时间',
key: 'registered_at',
width: 176,
render: (row) => formatTime(row.registered_at),
},
{
title: '数据截止',
key: 'log_synced_at',
width: 120,
render: (row) => (row.log_synced_at ? formatTimeShort(row.log_synced_at) : '同步中'),
},
{
title: '操作',
key: 'actions',
width: 110,
render: (row) => {
const busy = patchingId.value === row.id
if (row.status === 'active') {
return h(
NPopconfirm,
{ onPositiveClick: () => patch(row, 'disabled') },
{
trigger: () => h(NButton, { size: 'tiny', loading: busy }, { default: () => '停用' }),
default: () => '确认停用该 key?停用后不再轮询。',
},
)
}
return h(
NButton,
{
size: 'tiny',
type: 'primary',
loading: busy,
onClick: () => patch(row, 'active'),
},
{ default: () => '启用' },
)
},
},
])
/** 展开行:其余字段(总额度 / 模型限制 / 401 计数 / 额度新鲜度) */
function renderExpand(row: ApiKeyRow): ReturnType<typeof h> {
const limits = Object.keys(row.model_limits ?? {})
const items = [
{ label: '总额度', value: formatCount(row.total_granted) },
{ label: '无限额度', value: row.unlimited_quota ? '是' : '否' },
{ label: '模型限制', value: row.model_limits_enabled ? limits.join('、') || '(空)' : '未启用' },
{ label: '连续 401 计数', value: String(row.consecutive_401s) },
{
label: '额度新鲜度',
value: row.usage_synced_at ? formatTime(row.usage_synced_at) : '未采集',
},
{ label: '停用来源', value: row.disabled_source ?? '—' },
]
return h('div', { class: 'expand-grid' }, items.map((i) => h('div', [
h('span', { class: 'expand-label' }, `${i.label}:`),
h('span', i.value),
])))
}
</script>
<template>
<div class="api-keys">
<h2>key 管理</h2>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.2" />
<div class="header">
<h2>key 管理</h2>
<n-button type="primary" @click="showModal = true">+ 登记 key</n-button>
</div>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row: ApiKeyRow) => row.id"
:scroll-x="1280"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '还没有登记任何 key'" />
</template>
</n-data-table>
<n-modal
v-model:show="showModal"
preset="card"
title="登记 key"
class="register-modal"
:style="{ width: '480px' }"
>
<n-input
v-model:value="keyInput"
type="textarea"
:rows="3"
placeholder="粘贴 sk-key(仅登记本次粘贴的 key)"
/>
<n-text v-if="registerError" type="error" class="register-error">{{ registerError }}</n-text>
<template #footer>
<div class="modal-footer">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="registering" @click="submitRegister">登记</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header h2 {
margin: 0;
font-size: 18px;
}
.register-error {
display: block;
margin-top: 10px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.expand-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px 16px;
font-size: 13px;
}
:deep(.expand-label) {
color: #999;
}
</style>