- 筛选区: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 项
501 lines
15 KiB
Vue
501 lines
15 KiB
Vue
<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>
|