Files
new-api-key-usage/apps/web/src/views/UsageLogView.vue
T
skycurtain 0e499eb8e1 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 项
2026-09-15 01:00:15 +08:00

501 lines
15 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">
// 用量日志页(交互设计 §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 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>
<style scoped>
.usage-log {
max-width: 1200px;
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>