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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user