feat(usage): 统计表格新增输入/输出与缓存创建token细分展示

- 后端接口(admin/user_me/query)新增 output_tokens、cache_creation_tokens、total_input_context 字段返回
- 前端统计表格(模型/提供商/API格式)合并 Tokens 列为"输入/输出"+"缓存读取/创建"两行紧凑布局
- 合并"缓存Token"和"缓存命中率"为单一"命中率"列,减少表格宽度
- 修正 UsageRecordsTable 缩进及模板格式
This commit is contained in:
fawney19
2026-03-14 16:11:56 +08:00
parent aafd332198
commit 751a4d9111
11 changed files with 268 additions and 186 deletions

View File

@@ -16,16 +16,16 @@
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
<div class="flex flex-col text-xs gap-0.5">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
@@ -35,7 +35,7 @@
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="7"
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无API格式统计数据
@@ -52,7 +52,10 @@
{{ item.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(item.total_tokens) }}</span>
<div class="flex flex-col items-end text-xs gap-0.5">
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens(item.cache_read_tokens || 0) }} / {{ formatTokens(item.cache_creation_tokens || 0) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
@@ -66,10 +69,7 @@
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(item.cache_read_tokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(item.cache_hit_rate) }}
<span>{{ formatHitRate(item.cache_hit_rate) }}</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }}

View File

@@ -16,16 +16,16 @@
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
<div class="flex flex-col text-xs gap-0.5">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
效率
@@ -35,7 +35,7 @@
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="7"
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无模型统计数据
@@ -52,7 +52,10 @@
{{ model.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(model.total_tokens) }}</span>
<div class="flex flex-col items-end text-xs gap-0.5">
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }} / {{ formatTokens(model.cache_creation_tokens || 0) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
@@ -66,10 +69,7 @@
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(model.cache_read_tokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(model.cache_hit_rate) }}
<span>{{ formatHitRate(model.cache_hit_rate) }}</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }}

View File

@@ -16,16 +16,16 @@
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
<div class="flex flex-col text-xs gap-0.5">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
成功率
@@ -38,7 +38,7 @@
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="8"
:colspan="7"
class="text-center py-6 text-muted-foreground px-2"
>
暂无提供商统计数据
@@ -55,7 +55,10 @@
{{ provider.requests }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(provider.totalTokens) }}</span>
<div class="flex flex-col items-end text-xs gap-0.5">
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens(provider.cacheReadTokens || 0) }} / {{ formatTokens(provider.cacheCreationTokens || 0) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
@@ -69,10 +72,7 @@
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(provider.cacheReadTokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(provider.cacheHitRate) }}
<span>{{ formatHitRate(provider.cacheHitRate) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
@@ -108,5 +108,4 @@ function getSuccessRateClass(rate: number): string {
if (rate < 90) return 'text-destructive'
return '' // 默认颜色
}
</script>

View File

@@ -3,167 +3,167 @@
<template #actions>
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
/>
v-model="timeRangeModel"
:show-granularity="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 通用搜索 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
class="w-[7.5rem] sm:w-48 h-8 text-xs border-border/60 pl-8"
/>
</div>
<!-- 通用搜索 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
class="w-[7.5rem] sm:w-48 h-8 text-xs border-border/60 pl-8"
/>
</div>
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)"
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
<!-- 提供商筛选仅管理员可见 -->
<Select
v-if="isAdmin"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
<!-- 提供商筛选仅管理员可见 -->
<Select
v-if="isAdmin"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
<!-- API格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
<!-- API格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新每3秒刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新每3秒刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
</template>
<!-- 移动端卡片视图 -->

View File

@@ -110,7 +110,10 @@ export function useUsageData(options: UseUsageDataOptions) {
model: item.model,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
cache_creation_tokens: typeof raw.cache_creation_tokens === 'number' ? raw.cache_creation_tokens : 0,
cache_hit_rate: typeof raw.cache_hit_rate === 'number' ? raw.cache_hit_rate : 0,
total_cost: item.total_cost || 0,
actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
@@ -121,7 +124,10 @@ export function useUsageData(options: UseUsageDataOptions) {
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost,
actualCost: item.actual_cost,
@@ -135,7 +141,10 @@ export function useUsageData(options: UseUsageDataOptions) {
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost || 0,
actual_cost: item.actual_cost,
@@ -169,7 +178,10 @@ export function useUsageData(options: UseUsageDataOptions) {
model: item.model,
request_count: item.requests || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost_usd || 0,
actual_cost: item.actual_total_cost_usd
@@ -179,7 +191,10 @@ export function useUsageData(options: UseUsageDataOptions) {
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost_usd || 0,
successRate: item.success_rate || 0,
@@ -209,7 +224,10 @@ export function useUsageData(options: UseUsageDataOptions) {
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost_usd || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0

View File

@@ -22,7 +22,10 @@ export interface ModelStatsItem {
model: string
request_count: number
total_tokens: number
total_input_context?: number
output_tokens?: number
cache_read_tokens?: number
cache_creation_tokens?: number
cache_hit_rate?: number
total_cost: number
actual_cost?: number // 倍率消耗
@@ -38,7 +41,10 @@ export interface ProviderStatsItem {
provider: string
requests: number
totalTokens: number
totalInputContext?: number
outputTokens?: number
cacheReadTokens?: number
cacheCreationTokens?: number
cacheHitRate?: number
totalCost: number
actualCost?: number
@@ -51,7 +57,10 @@ export interface ApiFormatStatsItem {
api_format: string
request_count: number
total_tokens: number
total_input_context?: number
output_tokens?: number
cache_read_tokens?: number
cache_creation_tokens?: number
cache_hit_rate?: number
total_cost: number
actual_cost?: number