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 ./... 全绿。
310 lines
13 KiB
Vue
310 lines
13 KiB
Vue
<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>
|