Files
openteam/web/src/views/admin/UsageView.vue
T
Sakurasan d34d9bd2e6 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 ./... 全绿。
2026-10-30 13:53:25 +08:00

310 lines
13 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">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
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'
import type { UsageLog, User } from '@/types'
const toast = useToastStore()
const logs = ref<UsageLog[]>([])
const total = ref(0)
const page = ref(1)
const modelFilter = ref('')
const userFilter = ref('')
const userCandidates = ref<User[]>([])
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
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'
}
// 用户自动补全候选(防抖 300ms)
function onUserInput() {
if (userSearchTimer) clearTimeout(userSearchTimer)
const kw = userFilter.value.trim()
if (!kw) {
userCandidates.value = []
return
}
userSearchTimer = setTimeout(async () => {
try {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(kw)}&page_size=10`)
userCandidates.value = data.data.items ?? []
} catch {
userCandidates.value = []
}
}, 300)
}
// 用户名 → user_id(精确匹配),查不到返回 null
async function resolveUserId(username: string): Promise<number | null> {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(username)}&page_size=10`)
const items: User[] = data.data.items ?? []
const hit = items.find((u) => u.username === username)
return hit ? hit.id : null
}
async function load() {
try {
const params = new URLSearchParams()
params.set('page', String(page.value))
params.set('page_size', String(pageSize))
if (modelFilter.value.trim()) params.set('model', modelFilter.value.trim())
if (userFilter.value.trim()) {
const uid = await resolveUserId(userFilter.value.trim())
if (uid === null) {
toast.err(`未找到用户:${userFilter.value.trim()}`)
return
}
params.set('user_id', String(uid))
}
const { data } = await http.get(`/admin/usage?${params.toString()}`)
logs.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function clearUser() {
userFilter.value = ''
userCandidates.value = []
search()
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="relative">
<input
v-model="userFilter"
list="user-options"
placeholder="按用户过滤"
class="h-10 w-44 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
@input="onUserInput"
@keyup.enter="search"
/>
<button
v-if="userFilter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted hover:text-ink"
title="清除用户筛选"
@click="clearUser"
>
✕
</button>
</div>
<datalist id="user-options">
<option v-for="u in userCandidates" :key="u.id" :value="u.username" />
</datalist>
<input
v-model="modelFilter"
placeholder="按模型过滤"
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-48 sm:flex-none"
@keyup.enter="search"
/>
<Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<!-- 移动端:卡片列表 -->
<div class="space-y-3 p-3 lg:hidden">
<div v-for="l in logs" :key="l.id" class="card p-3">
<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">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
<span class="mono-num text-sm font-medium text-ink">{{ fmtCost(l.cost) }}</span>
</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">{{ 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>
</div>
</div>
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
</div>
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto lg:block">
<table class="w-full text-sm min-w-[900px]">
<thead>
<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>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</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>
<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>
</tr>
</thead>
<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 }}
<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">{{ 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="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>
</td>
</tr>
<tr v-if="logs.length === 0">
<td colspan="10" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
<Modal :open="!!viewing" title="原始请求与响应" width="max-w-3xl" @close="viewing = null">
<div v-if="viewing" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-muted">
<span class="font-mono text-ink">{{ viewing.model }}</span>
<span class="mx-1.5">·</span>
<span class="font-mono">{{ viewing.channel || '-' }}</span>
<span class="mx-1.5">·</span>
<span class="font-mono">{{ viewing.user || '-' }}</span>
<span class="mx-1.5">·</span>
<span class="mono-num">{{ fmtTime(viewing.created_at) }}</span>
</div>
<div class="flex gap-1 rounded-md border border-edge p-0.5">
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="viewTab === 'request' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="viewTab = 'request'"
>
请求
</button>
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="viewTab === 'response' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="viewTab = 'response'"
>
响应
</button>
</div>
</div>
<pre
v-if="viewTab === 'request' ? viewing.raw_request : viewing.raw_response"
class="max-h-[60vh] overflow-auto rounded-md border border-edge bg-surface p-3 font-mono text-xs leading-relaxed text-ink whitespace-pre-wrap break-all"
>{{ viewTab === 'request' ? viewing.raw_request : viewing.raw_response }}</pre>
<p v-else class="rounded-md border border-edge bg-surface p-4 text-center text-xs text-muted">
该请求未记录{{ viewTab === 'request' ? '原始请求' : '原始响应' }}
</p>
</div>
</Modal>
</div>
</template>