feat(web): 用量日志页(阶段 6)

- 筛选区:key 标签输入(粘贴多行/逗号拆分、去重、localStorage 记忆)+ datetimerange(默认近 7 天、今天/近7天/近30天 快捷项)+ 查询(点按才发请求)
- 卡片区:调用次数 / Token 用量 / 预估消费(2 位小数);未折算条数提示条;数据截止右端小字 + 悬停 per-key 明细(同步中 / 未收录 / 已停用)
- 明细表格:8 列 + 展开行(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组);错误行 Tag;分页器(页码 + 20/50/100/200)
- 六态:加载(卡片骨架 + 表格 loading)/ 初始引导 / 未收录提示 / 时段无记录 / 数据同步中… / 失败提示 + 重试
- 修复:naive-ui 展开行须写在列定义(type: 'expand');useMessage 显式导入(auto-imports 全局声明在 vue-tsc 6 下未生效)
- 走查:Playwright + 系统 Chrome 逐态断言与截图,无 console 错误;新增 format 工具单测 6 项
This commit is contained in:
2026-09-15 01:00:15 +08:00
parent 8252b25396
commit 0e499eb8e1
5 changed files with 577 additions and 7 deletions
+7
View File
@@ -11,9 +11,13 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NDataTable: typeof import('naive-ui')['NDataTable']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty']
NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout']
@@ -22,8 +26,11 @@ declare module 'vue' {
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSpace: typeof import('naive-ui')['NSpace']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
describe('formatTime', () => {
it('ISO(+08:00)→ `YYYY-MM-DD HH:mm:ss`', () => {
expect(formatTime('2026-09-11T19:37:12+08:00')).toBe('2026-09-11 19:37:12')
})
it('空值 → 「—」;无法识别原样返回', () => {
expect(formatTime(null)).toBe('—')
expect(formatTime(undefined)).toBe('—')
expect(formatTime('not-a-time')).toBe('not-a-time')
})
})
describe('formatTimeShort(当天简写)', () => {
const now = new Date('2026-09-11T13:00:00Z') // 北京时间 21:00
it('当天 → `HH:mm:ss`', () => {
expect(formatTimeShort('2026-09-11T19:37:12+08:00', now)).toBe('19:37:12')
})
it('跨日 → 完整形式', () => {
expect(formatTimeShort('2026-09-10T23:59:59+08:00', now)).toBe('2026-09-10 23:59:59')
})
})
describe('formatCount / formatAmount', () => {
it('千分位;null → 「—」', () => {
expect(formatCount(53939847)).toBe('53,939,847')
expect(formatCount(0)).toBe('0')
expect(formatCount(null)).toBe('—')
})
it('金额:汇总 2 位 / 明细 4 位;null → 「—」(不退化为额度美元)', () => {
expect(formatAmount(23.456)).toBe('23.46')
expect(formatAmount(0.008079785625, 4)).toBe('0.0081')
expect(formatAmount(0)).toBe('0.00')
expect(formatAmount(null)).toBe('—')
})
})
+39
View File
@@ -0,0 +1,39 @@
/**
* 展示格式化(交互设计 §2 #4/#5):
* - 时间统一到秒:`2026-09-11 19:37:12`(当天语境简写 `HH:mm:ss`)
* - 金额分层:汇总 2 位小数 / 明细行 4 位小数;`null` → 「—」(绝不退化为额度美元)
* - token 数与 quota 原值千分位;不标币种
*/
/** ISO 8601(后端统一 `+08:00`)→ `2026-09-11 19:37:12`;无法识别原样返回;空值 → 「—」 */
export function formatTime(iso: string | null | undefined): string {
if (!iso) return '—'
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso)
if (!m) return iso
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
}
/** 「今天」的北京时间日期串(`YYYY-MM-DD`)——输出时间自带 +08:00,按字符串前缀比较即可 */
function beijingDateString(now: Date): string {
return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10)
}
/** 当天语境简写 `HH:mm:ss`;跨日回落到完整形式 */
export function formatTimeShort(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return '—'
const full = formatTime(iso)
if (!/^\d{4}-\d{2}-\d{2} /.test(full)) return full
return iso.slice(0, 10) === beijingDateString(now) ? full.slice(11) : full
}
/** 千分位整数(token 数 / quota 原值 / 条数);null → 「—」 */
export function formatCount(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('en-US')
}
/** 实付人民币:汇总 2 位 / 明细行 4 位;null → 「—」 */
export function formatAmount(v: number | null | undefined, digits = 2): string {
if (v === null || v === undefined) return '—'
return v.toFixed(digits)
}
+485 -5
View File
@@ -1,13 +1,412 @@
<script setup lang="ts">
// 用量日志页(交互设计 §3.1)——骨架占位:
// 筛选区(key 标签输入 + datetimerange + 查询)→ 卡片区(次数 / 用量 / 消费)→ 未收录提示 → 明细表格 + 分页器
// 数据形状见 docs/design/API契约.md §2.1
// 用量日志页(交互设计 §3.1):筛选区(key 标签 + datetimerange + 快捷项)→ 卡片区
// → 未收录提示 → 明细表格(展开行)+ 分页器;六种状态模式见交互设计 §2 #6
import { computed, h, onMounted, ref } from 'vue'
import {
NDescriptions,
NDescriptionsItem,
NTag,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import {
KEYS_MAX,
LOGS_DEFAULT_LIMIT,
LOGS_LIMIT_OPTIONS,
LOG_TYPE,
LOG_TYPE_LABEL,
type LogsQueryResponse,
type QueryMetaKey,
type QueryMetaKeyRegistered,
type SummaryResponse,
type UsageLogRow,
} from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
const message = useMessage()
const DAY_MS = 24 * 3_600_000
const STORAGE_KEY = 'naku:usage-log-keys'
// ---- 筛选区状态(key 输入 localStorage 记住上次输入;点「查询」才发请求) ----
const keyTags = ref<string[]>([])
const range = ref<[number, number] | null>([Date.now() - 7 * DAY_MS, Date.now()])
const shortcuts = {
今天: (): [number, number] => {
const start = new Date()
start.setHours(0, 0, 0, 0)
return [start.getTime(), Date.now()]
},
近7天: (): [number, number] => [Date.now() - 7 * DAY_MS, Date.now()],
近30天: (): [number, number] => [Date.now() - 30 * DAY_MS, Date.now()],
}
onMounted(() => {
keyTags.value = loadStoredKeys()
})
function loadStoredKeys(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
const arr = raw ? (JSON.parse(raw) as unknown) : []
return Array.isArray(arr) ? arr.filter((k): k is string => typeof k === 'string') : []
} catch {
return []
}
}
/** 粘贴多行 / 逗号 / 分号自动拆分并按 key 去重 */
function normalizeTags(tags: string[]): string[] {
const out: string[] = []
for (const tag of tags) {
for (const part of String(tag).split(/[\s,,;;]+/)) {
const k = part.trim()
if (k && !out.includes(k)) out.push(k)
}
}
return out
}
function onTagsUpdate(value: string[]): void {
keyTags.value = normalizeTags(value)
}
// ---- 查询状态 ----
const loading = ref(false)
const error = ref<string | null>(null)
/** 是否已发起过查询(false = 初始态:页面中央引导,不渲染卡片与表格) */
const queried = ref(false)
const summary = ref<SummaryResponse | null>(null)
const logs = ref<LogsQueryResponse | null>(null)
const page = ref(1)
const pageSize = ref<number>(LOGS_DEFAULT_LIMIT)
/** 最近一次查询参数——翻页只复用(不重算汇总、不读输入框) */
const activeQuery = ref<{ keys: string[]; start: string; end: string } | null>(null)
function describeError(e: unknown): string {
if (e instanceof ApiError) return e.message
return e instanceof Error ? e.message : '未知错误'
}
async function runQuery(): Promise<void> {
const keys = normalizeTags(keyTags.value)
if (keys.length === 0) {
message.warning('请先输入至少一个 key')
return
}
if (keys.length > KEYS_MAX) {
message.warning(`一次最多查询 ${KEYS_MAX} 个 key(当前 ${keys.length} 个)`)
return
}
if (!range.value) {
message.warning('请选择时间范围')
return
}
const [startMs, endMs] = range.value
if (startMs >= endMs) {
message.warning('时间范围非法:开始必须早于结束')
return
}
keyTags.value = keys
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
} catch {
/* localStorage 不可用(隐私模式)时静默跳过 */
}
const q = {
keys,
start: new Date(startMs).toISOString(),
end: new Date(endMs).toISOString(),
}
activeQuery.value = q
page.value = 1
queried.value = true
loading.value = true
error.value = null
try {
// 约定 5 / §2.1:首次并行请求,翻页只调明细
const [summaryRes, logsRes] = await Promise.all([
api.usageSummary<SummaryResponse>(q),
api.usageLogsQuery<LogsQueryResponse>({
...q,
limit: pageSize.value,
offset: 0,
}),
])
summary.value = summaryRes
logs.value = logsRes
} catch (e) {
error.value = describeError(e)
summary.value = null
logs.value = null
} finally {
loading.value = false
}
}
/** 翻页 / 改每页条数:只调明细端点(汇总不重算) */
async function loadLogs(): Promise<void> {
const q = activeQuery.value
if (!q) return
loading.value = true
error.value = null
try {
logs.value = await api.usageLogsQuery<LogsQueryResponse>({
...q,
limit: pageSize.value,
offset: (page.value - 1) * pageSize.value,
})
} catch (e) {
error.value = describeError(e)
} finally {
loading.value = false
}
}
function onPageSizeChange(): void {
page.value = 1
void loadLogs()
}
// ---- 派生状态 ----
const unregistered = computed(() =>
(summary.value?.meta.keys ?? []).filter((k): k is Extract<QueryMetaKey, { registered: false }> => !k.registered),
)
const registeredKeys = computed(() =>
(summary.value?.meta.keys ?? []).filter((k): k is QueryMetaKeyRegistered => k.registered),
)
/** 数据截止:null = 参与 key 全部未首同步(交互设计「数据同步中…」) */
const dataUntil = computed(() => summary.value?.meta.data_until ?? null)
const dataUntilText = computed(() =>
dataUntil.value ? formatTimeShort(dataUntil.value) : '数据同步中…',
)
// ---- 明细表格 ----
const columns = computed<DataTableColumns<UsageLogRow>>(() => [
// 展开列(naive-ui 要求展开能力写在列定义里:type: 'expand' + renderExpand)
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
{ title: '时间', key: 'created_at', width: 176, render: (row) => formatTime(row.created_at) },
{ title: 'key', key: 'token_name', width: 120, ellipsis: { tooltip: true } },
{ title: '模型', key: 'model_name', minWidth: 160, ellipsis: { tooltip: true } },
{
title: '类型',
key: 'type',
width: 88,
render: (row) =>
h(
NTag,
{
size: 'small',
bordered: false,
type: row.type === LOG_TYPE.Error ? 'error' : 'default',
},
{ default: () => LOG_TYPE_LABEL[row.type] ?? String(row.type) },
),
},
{
title: '输入 tokens',
key: 'prompt_tokens',
width: 116,
align: 'right',
render: (row) => formatCount(row.prompt_tokens),
},
{
title: '输出 tokens',
key: 'completion_tokens',
width: 116,
align: 'right',
render: (row) => formatCount(row.completion_tokens),
},
{
title: '消费',
key: 'amount_rmb',
width: 104,
align: 'right',
render: (row) => formatAmount(row.amount_rmb, 4),
},
{ title: '耗时', key: 'use_time', width: 80, align: 'right', render: (row) => `${row.use_time}s` },
])
/** 展开行:完整字段(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组) */
function renderExpand(row: UsageLogRow): ReturnType<typeof h> {
const other = row.other ?? {}
const text = (v: unknown): string =>
v === null || v === undefined || v === '' ? '—' : typeof v === 'number' ? formatCount(v) : String(v)
const items: Array<{ label: string; value: string }> = [
{ label: '缓存 tokens', value: text(other.cache_tokens) },
{ label: '流式', value: row.is_stream ? '是' : '否' },
{ label: 'quota 原值', value: formatCount(row.quota) },
{ label: '请求路径', value: text(other.request_path) },
{ label: 'request_id', value: text(row.request_id) },
{ label: 'upstream_request_id', value: text(row.upstream_request_id) },
{ label: '渠道', value: `${row.channel}${row.channel_name ? `(${row.channel_name})` : ''}` },
{ label: '分组', value: text(row.group_name) },
{ label: 'token_id', value: String(row.token_id) },
{ label: '账号', value: `${row.username}(#${row.user_id})` },
{ label: 'IP', value: text(row.ip) },
{ label: '订阅', value: text(other.subscription_plan_title) },
{ label: '订阅 ID', value: text(other.subscription_id) },
{ label: '订阅总额度', value: text(other.subscription_total) },
{ label: '订阅已用', value: text(other.subscription_used) },
{ label: '订阅剩余', value: text(other.subscription_remain) },
{ label: '计费来源', value: text(other.billing_source) },
{ label: '模型倍率', value: text(other.model_ratio) },
]
if (row.content) items.push({ label: '错误详情', value: row.content })
return h(
NDescriptions,
{ column: 3, size: 'small', bordered: true, labelPlacement: 'top' },
{
default: () =>
items.map((item) =>
h(NDescriptionsItem, { label: item.label, key: item.label }, { default: () => item.value }),
),
},
)
}
</script>
<template>
<div class="usage-log">
<h1>用量日志</h1>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.1" />
<h1 class="page-title">用量查询</h1>
<!-- 筛选区 -->
<n-card size="small" class="filter-card">
<div class="filter-row">
<n-dynamic-tags
class="key-input"
:value="keyTags"
:disabled="loading"
:max="20"
@update:value="onTagsUpdate"
/>
<n-date-picker
v-model:value="range"
type="datetimerange"
clearable
:shortcuts="shortcuts"
class="range-input"
/>
<n-button type="primary" :loading="loading" @click="runQuery">查询</n-button>
</div>
<div class="filter-hint">粘贴 sk-key(多行 / 逗号一次输入多个);时间范围默认最近 7 天</div>
</n-card>
<!-- 初始态(未输入 key) -->
<div v-if="!queried" class="initial-empty">
<n-empty size="large" description="输入你的 key 开始查询" />
</div>
<template v-else>
<!-- 查询失败(网络 / 5xx) -->
<n-alert v-if="error" type="error" class="alert" title="查询失败">
<div class="alert-body">
<span>{{ error }}</span>
<n-button size="tiny" @click="activeQuery ? loadLogs() : runQuery">重试</n-button>
</div>
</n-alert>
<!-- 卡片区(加载中显示骨架) -->
<div class="cards">
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">调用次数</div>
<div class="card-value">{{ formatCount(summary?.request_count ?? 0) }}</div>
<div class="card-sub">
消费 {{ formatCount(summary?.consume_count ?? 0) }} · 错误
{{ formatCount(summary?.error_count ?? 0) }}
</div>
</template>
</n-card>
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">Token 用量</div>
<div class="card-value">
{{
formatCount(
(summary?.prompt_tokens ?? 0) + (summary?.completion_tokens ?? 0),
)
}}
</div>
<div class="card-sub">缓存命中 {{ formatCount(summary?.cache_tokens ?? 0) }}</div>
</template>
</n-card>
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">预估消费</div>
<div class="card-value amount">{{ formatAmount(summary?.amount_rmb ?? 0) }}</div>
<div class="card-sub">额度记账 {{ formatCount(summary?.quota ?? 0) }}</div>
</template>
</n-card>
<div class="data-until">
<n-tooltip trigger="hover" :disabled="registeredKeys.length === 0">
<template #trigger>
<span class="data-until-text">数据截止 {{ dataUntilText }}</span>
</template>
<div class="until-detail">
<div v-for="k in summary?.meta.keys ?? []" :key="k.registered ? k.key_id : k.key_hint">
<template v-if="k.registered">
{{ k.token_name }}:
{{ k.log_synced_at ? formatTimeShort(k.log_synced_at) : '数据同步中…' }}
<span class="until-status">
({{ k.status === 'disabled' ? '已停用' : '运行中' }})
</span>
</template>
<template v-else>{{ k.key_hint }}:未收录</template>
</div>
</div>
</n-tooltip>
</div>
</div>
<!-- 未收录提示条 -->
<n-alert v-if="unregistered.length > 0" type="warning" class="alert">
以下 key 未收录:{{ unregistered.map((k) => k.key_hint).join('、') }}(请联系管理员登记)
</n-alert>
<!-- 未折算提示(金额口径说明) -->
<n-alert v-if="(summary?.unpriced_count ?? 0) > 0" type="info" class="alert" :bordered="false">
其中 {{ formatCount(summary?.unpriced_count) }} 条未登记购买、未计入金额(明细中显示「—」)
</n-alert>
<!-- 明细表格 -->
<n-data-table
:columns="columns"
:data="logs?.items ?? []"
:loading="loading"
:row-key="(row: UsageLogRow) => row.id"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '该时段没有记录'" />
</template>
</n-data-table>
<!-- 分页器 -->
<div class="pager">
<n-pagination
v-model:page="page"
v-model:page-size="pageSize"
:item-count="logs?.total ?? 0"
:page-sizes="[...LOGS_LIMIT_OPTIONS]"
show-size-picker
@update:page="loadLogs"
@update:page-size="onPageSizeChange"
/>
</div>
</template>
</div>
</template>
@@ -17,4 +416,85 @@
margin: 0 auto;
padding: 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.filter-row {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.key-input {
flex: 1 1 420px;
min-width: 320px;
}
.range-input {
width: 380px;
}
.filter-hint {
margin-top: 8px;
color: #999;
font-size: 12px;
}
.initial-empty {
padding: 120px 0;
}
.alert {
margin-top: 16px;
}
.alert-body {
display: flex;
align-items: center;
gap: 12px;
}
.cards {
display: flex;
gap: 12px;
align-items: stretch;
margin-top: 16px;
}
.card {
flex: 1 1 0;
}
.card-label {
color: #666;
font-size: 13px;
}
.card-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.card-sub {
margin-top: 6px;
color: #999;
font-size: 12px;
}
.data-until {
display: flex;
align-items: flex-end;
padding-bottom: 6px;
white-space: nowrap;
}
.data-until-text {
color: #999;
font-size: 12px;
cursor: default;
}
.until-detail {
font-size: 12px;
line-height: 1.7;
}
.until-status {
color: #bbb;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>