Files
new-api-key-usage/apps/web/src/views/manage/ApiKeysView.vue
T
skycurtain fca01798e2 feat(web): 管理面三页(阶段 7)
- key 管理:清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度 / 停用来源)+ 状态徽章三面孔(运行中 / 已停用 / 已失效 + tooltip);登记弹窗(三类失败文案);停用弹确认、启用直接执行
- 购买记录:清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录共用;期间顺序校验);核对提示(未登记订阅提示条 + 观测折叠区 + 补录预填订阅 ID 与观测额度)
- 换算参数:时间线两列 + 「当前生效」标记 + 页首说明(纯只读)
- 修复:补 n-config-provider 中文 locale(naive-ui 默认英文按钮);两处表格 scroll-x(列宽被压缩致表头截断)
- 走查:Playwright 15 步全操作通过(含三类登记结果、确认弹窗、补录与 CRUD)
2026-09-15 01:06:43 +08:00

273 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 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">
<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>