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