feat(web): 用量明细展示缓存读/写与上游实际模型,补 1h 缓存写与思考口径

API:/api/v1/usage/logs 与 /api/v1/admin/usage 返回 cache_creation_tokens、
reasoning_tokens、cache_write_1h_tokens、served_model(types.ts 同步可选字段)。

前端:
- 模型列在被模型映射/聚合商改写时显示「→ 上游实际模型」,长名截断并可悬浮看全;
- 表格里 Token 列第二行显示缓存读/写(与成本直接相关),1h 缓存写与思考 token
  放悬浮提示——两者直接上表格会把 Token 列撑到 255px,1280 宽下把「时间/原始」
  挤出视野(实测 1146px vs 容器 1006px);移动卡两行都显示;
- 用量表协议列由 chat/completions 简化为 chat(表头已标明「API 格式」),
  时间列改月-日 时:分、完整时间进 title;调整后桌面端表格宽度 1006px,
  与容器一致、无横向滚动;
- 顺带修复控制台用量页 Button 未导入(上一页/下一页此前渲染不出来)。

验证:本地 mock 上游 + 临时 sqlite 实例与真实数据实测;1280 桌面与 390 窄屏
截图核对(表格 tableW == 容器宽度);vue-tsc、vitest、eslint、vite build 与
go vet、go test ./... 全绿。
This commit is contained in:
Sakurasan committed 2026-10-30 13:53:25 +08:00
1 parent 04dfb4285d
commit d34d9bd2e6
5 files changed
+135 -18

No files matched your search

+7
View File
@@ -112,6 +112,13 @@ export interface UsageLog {
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_creation_tokens?: number
/** 思考 token:上游已计入 output_tokens,仅展示口径 */
reasoning_tokens?: number
/** Anthropic 1h 缓存写(计价 2× 输入);含在 cache_creation_tokens 内 */
cache_write_1h_tokens?: number
/** 上游实际应答的模型名;模型映射/聚合商场景下与 model 不同 */
served_model?: string
cost: number
latency_ms: number
status: string
+68 -9
View File
@@ -2,8 +2,7 @@
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { protocolShort } from '@/lib/protocol'
import { fmtCost, fmtTime } from '@/lib/format'
import { fmtCost, fmtNum, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue'
@@ -22,6 +21,50 @@ const pageSize = 15
const viewing = ref<UsageLog | null>(null)
const viewTab = ref<'request' | 'response'>('request')
// cacheLine 缓存读/写的紧凑摘要(换行会碎成多行,只放两个数);无缓存流量返回空串。
function cacheLine(l: UsageLog): string {
const read = l.cache_read_tokens || 0
const write = l.cache_creation_tokens || 0
const parts: string[] = []
if (read) parts.push(`读${fmtNum(read)}`)
if (write) parts.push(`写${fmtNum(write)}`)
return parts.join(' ')
}
// cacheExtra 第二行:1h 缓存写(计费 2× 输入)与思考 token,两者都没有则返回空串。
function cacheExtra(l: UsageLog): string {
const oneHour = l.cache_write_1h_tokens || 0
const thinking = l.reasoning_tokens || 0
const parts: string[] = []
if (oneHour) parts.push(`1h${fmtNum(oneHour)}`)
if (thinking) parts.push(`思考${fmtNum(thinking)}`)
return parts.join(' · ')
}
// fmtClock 表格里的紧凑时间(月-日 时:分):完整时间在悬浮提示里,
// 否则这一列在 1280 宽下会把「原始」列挤出视野。
function fmtClock(s?: string | null): string {
if (!s) return '-'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
})
}
// tokenTitle Token 列的悬浮提示:表格里只放缓存读/写(与成本直接相关),
// 1h 缓存写与思考 token 放提示里,避免把列撑宽挤出其它列。
function tokenTitle(l: UsageLog): string {
const extra = cacheExtra(l)
const base = `输入 ${l.input_tokens} / 输出 ${l.output_tokens}`
return extra ? `${base} · ${extra}` : base
}
// servedModel 上游实际应答的模型:仅在模型映射/聚合商改写时与请求模型不同才展示。
function servedModel(l: UsageLog): string {
return l.served_model && l.served_model !== l.model ? l.served_model : ''
}
function openRaw(l: UsageLog) {
viewing.value = l
viewTab.value = 'request'
@@ -140,6 +183,7 @@ onMounted(load)
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
<p v-if="servedModel(l)" class="truncate font-mono text-xs text-muted">→ {{ servedModel(l) }}</p>
<p class="mt-0.5 truncate text-xs text-muted">{{ l.channel }} · {{ l.user }}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
@@ -148,8 +192,10 @@ onMounted(load)
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
<span class="font-mono">{{ protocolShort(l.protocol) }}</span>
<span class="font-mono">{{ l.protocol }}</span>
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
<span v-if="cacheLine(l)" class="mono-num">缓存 {{ cacheLine(l) }}</span>
<span v-if="cacheExtra(l)" class="mono-num">{{ cacheExtra(l) }}</span>
<span class="mono-num">{{ l.latency_ms }}ms</span>
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
<Button v-if="l.raw_request" size="sm" variant="ghost" class="ml-auto" @click="openRaw(l)">查看原始</Button>
@@ -160,9 +206,9 @@ onMounted(load)
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto lg:block">
<table class="w-full text-sm min-w-[760px]">
<table class="w-full text-sm min-w-[900px]">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<tr class="whitespace-nowrap border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">渠道</th>
@@ -178,16 +224,29 @@ onMounted(load)
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">
{{ l.model }}
<p v-if="servedModel(l)" :title="servedModel(l)" class="max-w-[118px] truncate text-[11px] text-muted">
→ {{ servedModel(l) }}
</p>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.channel || '-' }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.protocol }}</td>
<td
class="whitespace-nowrap px-4 py-2.5 mono-num text-xs text-muted"
:title="tokenTitle(l)"
>
<span>{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<p v-if="cacheLine(l)" class="text-[11px]">缓存 {{ cacheLine(l) }}</p>
</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-muted" :title="fmtTime(l.created_at)">
{{ fmtClock(l.created_at) }}
</td>
<td class="px-4 py-2.5">
<Button v-if="l.raw_request" size="sm" variant="ghost" @click="openRaw(l)">查看</Button>
<span v-else class="text-xs text-muted">-</span>
+54 -7
View File
@@ -3,8 +3,8 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { protocolShort } from '@/lib/protocol'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
@@ -35,6 +35,39 @@ async function load() {
}
}
// cacheLine 缓存读/写的紧凑摘要(换行会碎成多行,只放两个数);无缓存流量返回空串。
function cacheLine(l: UsageLog): string {
const read = l.cache_read_tokens || 0
const write = l.cache_creation_tokens || 0
const parts: string[] = []
if (read) parts.push(`读${fmtNum(read)}`)
if (write) parts.push(`写${fmtNum(write)}`)
return parts.join(' ')
}
// cacheExtra 第二行:1h 缓存写(计费 2× 输入)与思考 token,两者都没有则返回空串。
function cacheExtra(l: UsageLog): string {
const oneHour = l.cache_write_1h_tokens || 0
const thinking = l.reasoning_tokens || 0
const parts: string[] = []
if (oneHour) parts.push(`1h${fmtNum(oneHour)}`)
if (thinking) parts.push(`思考${fmtNum(thinking)}`)
return parts.join(' · ')
}
// tokenTitle Token 列的悬浮提示:表格里只放缓存读/写(与成本直接相关),
// 1h 缓存写与思考 token 放提示里,避免把列撑宽挤出其它列。
function tokenTitle(l: UsageLog): string {
const extra = cacheExtra(l)
const base = `输入 ${l.input_tokens} / 输出 ${l.output_tokens}`
return extra ? `${base} · ${extra}` : base
}
// servedModel 上游实际应答的模型:仅在模型映射/聚合商改写时与请求模型不同才展示。
function servedModel(l: UsageLog): string {
return l.served_model && l.served_model !== l.model ? l.served_model : ''
}
function switchGroup(g: 'day' | 'model') {
group.value = g
load()
@@ -114,7 +147,8 @@ onMounted(load)
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</p>
<p v-if="servedModel(l)" class="truncate font-mono text-xs text-muted">→ {{ servedModel(l) }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ l.protocol }}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
@@ -123,6 +157,8 @@ onMounted(load)
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
<span v-if="cacheLine(l)" class="mono-num">缓存 {{ cacheLine(l) }}</span>
<span v-if="cacheExtra(l)" class="mono-num">{{ cacheExtra(l) }}</span>
<span class="mono-num">{{ l.latency_ms }}ms</span>
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
</div>
@@ -132,9 +168,9 @@ onMounted(load)
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto lg:block">
<table class="w-full text-sm min-w-[640px]">
<table class="w-full text-sm min-w-[720px]">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<tr class="whitespace-nowrap border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">协议</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
@@ -146,9 +182,20 @@ onMounted(load)
</thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">
{{ l.model }}
<p v-if="servedModel(l)" :title="servedModel(l)" class="max-w-[118px] truncate text-[11px] text-muted">
→ {{ servedModel(l) }}
</p>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.protocol }}</td>
<td
class="whitespace-nowrap px-4 py-2.5 mono-num text-xs text-muted"
:title="tokenTitle(l)"
>
<span>{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<p v-if="cacheLine(l)" class="text-[11px]">缓存 {{ cacheLine(l) }}</p>
</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge></td>