Skip to content

Commit 1a3cc2a

Browse files
committed
refactor: 重新设计用量页布局并新增延迟健康列
- /admin/usage 明细区收进单卡片:三个下划线 tab(用量明细/错误请求/用户排行) + 自适应筛选栏 + 内容 - 用户 Token 排行收编为第三个 tab:去掉独立邮箱搜索,懒挂载,点击行下钻回用量明细并回显用户 - 首Token/耗时合并为延迟列(首字/总耗时):阈值 10s/30s/60s 与 1min/3min/5min 四档配色,色条上下两段短渐变过渡(from-40% to-60%),管理端与用户端 /usage 共用 - 时长格式 ≥1min 简化为 Xm Ys、≥1h 为 Xh Ym - UsageFilters/UsageTable/OpsErrorLogTable 增加 flat 嵌入模式(默认关闭不影响其他使用方)
1 parent e2c731a commit 1a3cc2a

14 files changed

Lines changed: 477 additions & 168 deletions

File tree

frontend/src/components/admin/usage/UsageFilters.vue

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="card p-6">
2+
<div :class="flat ? 'p-4 sm:p-6' : 'card p-6'">
33
<!-- Toolbar: left filters (multi-line) + right actions -->
44
<div class="flex flex-wrap items-end justify-between gap-4">
55
<!-- Left: filters (allowed to wrap to multiple rows) -->
@@ -133,8 +133,8 @@
133133
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
134134
</div>
135135

136-
<!-- Billing Mode Filter (usage only) -->
137-
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
136+
<!-- Billing Mode Filter (usage only;用户排行的 user-breakdown 接口不支持该维度) -->
137+
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[200px]">
138138
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
139139
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
140140
</div>
@@ -174,7 +174,7 @@
174174
{{ t('common.reset') }}
175175
</button>
176176
<slot name="after-reset" />
177-
<template v-if="mode !== 'errors'">
177+
<template v-if="mode === 'usage'">
178178
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
179179
{{ t('admin.usage.cleanup.button') }}
180180
</button>
@@ -204,13 +204,19 @@ interface Props {
204204
endDate: string
205205
showActions?: boolean
206206
modelOptions?: string[]
207-
/** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */
208-
mode?: 'usage' | 'errors'
207+
/**
208+
* errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用)
209+
* ranking 模式:同 usage 但隐藏计费模式筛选与清理/导出按钮(用户排行 tab 用)
210+
*/
211+
mode?: 'usage' | 'errors' | 'ranking'
212+
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
213+
flat?: boolean
209214
}
210215
211216
const props = withDefaults(defineProps<Props>(), {
212217
showActions: true,
213-
mode: 'usage'
218+
mode: 'usage',
219+
flat: false
214220
})
215221
const emit = defineEmits([
216222
'update:modelValue',
@@ -485,4 +491,13 @@ onMounted(async () => {
485491
onUnmounted(() => {
486492
document.removeEventListener('click', onDocumentClick)
487493
})
494+
495+
// 供外部(如用户排行下钻)在程序化设置 user_id 后回显选中的用户邮箱
496+
const setUserKeyword = (email: string) => {
497+
userKeyword.value = email
498+
userResults.value = []
499+
showUserDropdown.value = false
500+
}
501+
502+
defineExpose({ setUserKeyword })
488503
</script>

frontend/src/components/admin/usage/UsageTable.vue

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="card overflow-hidden">
2+
<div :class="flat ? '' : 'card overflow-hidden'">
33
<div
44
v-if="showIpGeoToolbar"
55
class="flex items-center justify-end gap-2 border-b border-gray-200 px-4 py-2 dark:border-dark-700"
@@ -185,13 +185,24 @@
185185
</div>
186186
</template>
187187

188-
<template #cell-first_token="{ row }">
189-
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
190-
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
191-
</template>
192-
193-
<template #cell-duration="{ row }">
194-
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
188+
<!-- 合并首字/总耗时的健康度列:左侧色条上端随首字档、下端随总耗时档,中段(40%-60%)短渐变过渡,便于纵向扫视整体健康状况 -->
189+
<template #cell-latency="{ row }">
190+
<div class="flex items-stretch gap-2">
191+
<span
192+
class="w-1 shrink-0 rounded-full"
193+
:class="row.first_token_ms != null
194+
? ['bg-gradient-to-b from-40% to-60%', LATENCY_BAR_FROM_CLASSES[firstTokenSeverity(row.first_token_ms)], LATENCY_BAR_TO_CLASSES[durationSeverity(row.duration_ms ?? 0)]]
195+
: LATENCY_BAR_CLASSES[durationSeverity(row.duration_ms ?? 0)]"
196+
aria-hidden="true"
197+
></span>
198+
<div class="grid grid-cols-[max-content_max-content] items-baseline gap-x-2 gap-y-0.5 text-xs">
199+
<span class="text-gray-400 dark:text-gray-500">{{ t('usage.latencyFirstToken') }}</span>
200+
<span v-if="row.first_token_ms != null" class="font-medium tabular-nums" :class="LATENCY_TEXT_CLASSES[firstTokenSeverity(row.first_token_ms)]">{{ formatDuration(row.first_token_ms) }}</span>
201+
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
202+
<span class="text-gray-400 dark:text-gray-500">{{ t('usage.latencyDuration') }}</span>
203+
<span class="font-medium tabular-nums" :class="LATENCY_TEXT_CLASSES[durationSeverity(row.duration_ms ?? 0)]">{{ formatDuration(row.duration_ms) }}</span>
204+
</div>
205+
</div>
195206
</template>
196207

197208
<template #cell-created_at="{ value }">
@@ -430,6 +441,14 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
430441
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
431442
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
432443
import { resolveUsageRequestType } from '@/utils/usageRequestType'
444+
import {
445+
LATENCY_BAR_CLASSES,
446+
LATENCY_BAR_FROM_CLASSES,
447+
LATENCY_BAR_TO_CLASSES,
448+
LATENCY_TEXT_CLASSES,
449+
durationSeverity,
450+
firstTokenSeverity,
451+
} from '@/utils/latencyHealth'
433452
import {
434453
BILLING_MODE_TOKEN,
435454
getBillingModeLabel,
@@ -474,6 +493,8 @@ interface Props {
474493
defaultSortOrder?: 'asc' | 'desc'
475494
showAccountBilling?: boolean
476495
showUpstreamEndpoint?: boolean
496+
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
497+
flat?: boolean
477498
}
478499
479500
const props = withDefaults(defineProps<Props>(), {
@@ -482,7 +503,8 @@ const props = withDefaults(defineProps<Props>(), {
482503
defaultSortKey: '',
483504
defaultSortOrder: 'asc',
484505
showAccountBilling: true,
485-
showUpstreamEndpoint: true
506+
showUpstreamEndpoint: true,
507+
flat: false
486508
})
487509
const emit = defineEmits<{
488510
userClick: [userID: number, email?: string]
@@ -552,10 +574,14 @@ const formatUserAgent = (ua: string): string => {
552574
return ua
553575
}
554576
577+
// 超过 1 分钟简化为 "Xm Ys",免去人工换算(超过 1 小时再进位为 "Xh Ym")
555578
const formatDuration = (ms: number | null | undefined): string => {
556579
if (ms == null) return '-'
557580
if (ms < 1000) return `${ms}ms`
558-
return `${(ms / 1000).toFixed(2)}s`
581+
if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
582+
const totalSec = Math.round(ms / 1000)
583+
if (totalSec < 3600) return `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`
584+
return `${Math.floor(totalSec / 3600)}h ${Math.floor((totalSec % 3600) / 60)}m`
559585
}
560586
561587
// Cost tooltip functions

frontend/src/components/admin/usage/UserTokenRanking.vue

Lines changed: 49 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,91 @@
11
<template>
2-
<div class="card p-4">
3-
<!-- Header -->
4-
<div class="mb-3 flex flex-wrap items-center gap-3">
5-
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">
6-
{{ t('admin.usage.tokenRanking.title') }}
7-
</h3>
8-
<span class="text-xs text-gray-400">{{ t('admin.usage.tokenRanking.subtitle') }}</span>
9-
<div class="ml-auto flex flex-wrap items-center gap-2">
10-
<input
11-
v-model="search"
12-
type="text"
13-
class="input h-8 w-44 text-sm"
14-
:placeholder="t('admin.usage.tokenRanking.searchPlaceholder')"
15-
/>
2+
<!-- 用量页"用户排行"tab 内容:无卡片外观,依赖父级统一卡片;筛选/时间范围复用页面级筛选栏 -->
3+
<div>
4+
<!-- Toolbar -->
5+
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-4 py-3 dark:border-dark-700/50 sm:px-6">
6+
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('admin.usage.tokenRanking.subtitle') }}</p>
7+
<div class="flex items-center gap-3">
8+
<span v-if="!loading && items.length > 0" class="text-xs text-gray-400 dark:text-gray-500">
9+
{{ t('admin.usage.tokenRanking.userCount', { count: items.length }) }}
10+
</span>
1611
<div class="w-28">
1712
<Select v-model="limit" :options="limitOptions" @change="load" />
1813
</div>
19-
<button
20-
type="button"
21-
class="btn btn-secondary h-8 px-2"
22-
:title="t('common.refresh')"
23-
@click="load"
24-
>
25-
<Icon name="refresh" size="sm" :class="{ 'animate-spin': loading }" />
26-
</button>
2714
</div>
2815
</div>
2916

3017
<!-- Table -->
3118
<div class="overflow-x-auto">
32-
<table class="w-full text-sm">
33-
<thead>
34-
<tr class="border-b border-gray-100 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
35-
<th class="w-10 py-2 pr-2 text-left font-medium">#</th>
36-
<th class="py-2 pr-2 text-left font-medium">{{ t('admin.usage.tokenRanking.columns.user') }}</th>
19+
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
20+
<thead class="bg-gray-50 dark:bg-dark-800">
21+
<tr>
22+
<th class="w-16 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400 sm:px-6">#</th>
23+
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
24+
{{ t('admin.usage.tokenRanking.columns.user') }}
25+
</th>
3726
<th
3827
v-for="col in sortableColumns"
3928
:key="col.key"
40-
class="cursor-pointer select-none py-2 pl-2 text-right font-medium hover:text-primary-500"
41-
:class="{ 'text-primary-600 dark:text-primary-400': sortBy === col.key }"
29+
class="cursor-pointer select-none whitespace-nowrap px-4 py-3 text-right text-xs font-medium uppercase tracking-wider transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
30+
:class="sortBy === col.key ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-dark-400'"
4231
@click="setSort(col.key)"
4332
>
4433
{{ t(col.label) }}
4534
<span v-if="sortBy === col.key" aria-hidden="true">↓</span>
4635
</th>
4736
</tr>
4837
</thead>
49-
<tbody>
38+
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
5039
<tr v-if="loading">
51-
<td :colspan="sortableColumns.length + 2" class="py-8 text-center">
40+
<td :colspan="sortableColumns.length + 2" class="py-12 text-center">
5241
<LoadingSpinner />
5342
</td>
5443
</tr>
55-
<tr v-else-if="displayItems.length === 0">
56-
<td :colspan="sortableColumns.length + 2" class="py-8 text-center text-sm text-gray-400">
44+
<tr v-else-if="items.length === 0">
45+
<td :colspan="sortableColumns.length + 2" class="py-12 text-center text-sm text-gray-400">
5746
{{ t('admin.dashboard.noDataAvailable') }}
5847
</td>
5948
</tr>
6049
<tr
61-
v-for="(item, index) in displayItems"
50+
v-for="(item, index) in items"
6251
v-else
6352
:key="item.user_id"
64-
class="cursor-pointer border-b border-gray-50 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-dark-700/40"
53+
class="cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/40"
54+
:title="t('admin.usage.tokenRanking.rowHint')"
6555
@click="$emit('select-user', item.user_id, item.email)"
6656
>
67-
<td class="py-2 pr-2 text-gray-400">{{ index + 1 }}</td>
68-
<td class="max-w-[220px] truncate py-2 pr-2 text-gray-700 dark:text-gray-200" :title="item.email">
57+
<td class="px-4 py-3 sm:px-6">
58+
<span
59+
v-if="index < 3"
60+
class="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold"
61+
:class="RANK_BADGE_CLASSES[index]"
62+
>{{ index + 1 }}</span>
63+
<span v-else class="inline-block w-6 text-center text-sm tabular-nums text-gray-400">{{ index + 1 }}</span>
64+
</td>
65+
<td class="max-w-[260px] truncate px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-200" :title="item.email">
6966
{{ item.email || `User #${item.user_id}` }}
67+
<span class="ml-1 font-normal text-gray-400 dark:text-gray-500">#{{ item.user_id }}</span>
7068
</td>
71-
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ item.requests.toLocaleString() }}</td>
72-
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.input_tokens) }}</td>
73-
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.output_tokens) }}</td>
74-
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.cache_tokens) }}</td>
75-
<td class="py-2 pl-2 text-right font-medium text-gray-800 dark:text-gray-100">{{ fmtTokens(item.total_tokens) }}</td>
76-
<td class="py-2 pl-2 text-right text-green-600 dark:text-green-400">${{ fmtCost(item.actual_cost) }}</td>
69+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm tabular-nums text-gray-500 dark:text-gray-400">{{ item.requests.toLocaleString() }}</td>
70+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm tabular-nums text-gray-500 dark:text-gray-400">{{ fmtTokens(item.input_tokens) }}</td>
71+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm tabular-nums text-gray-500 dark:text-gray-400">{{ fmtTokens(item.output_tokens) }}</td>
72+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm tabular-nums text-gray-500 dark:text-gray-400">{{ fmtTokens(item.cache_tokens) }}</td>
73+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium tabular-nums text-gray-900 dark:text-gray-100">{{ fmtTokens(item.total_tokens) }}</td>
74+
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium tabular-nums text-green-600 dark:text-green-400">${{ fmtCost(item.actual_cost) }}</td>
7775
</tr>
7876
</tbody>
7977
</table>
8078
</div>
81-
82-
<!-- Footer -->
83-
<div v-if="!loading && items.length > 0" class="mt-2 text-right text-xs text-gray-400">
84-
{{ t('admin.usage.tokenRanking.userCount', { count: displayItems.length }) }}
85-
</div>
8679
</div>
8780
</template>
8881

8982
<script setup lang="ts">
90-
import { ref, computed, watch } from 'vue'
83+
import { ref, watch } from 'vue'
9184
import { useI18n } from 'vue-i18n'
9285
import { getUserBreakdown, type UserBreakdownParams } from '@/api/admin/dashboard'
9386
import { formatCompactNumber, formatCostFixed } from '@/utils/format'
9487
import type { UserBreakdownItem } from '@/types'
9588
import Select from '@/components/common/Select.vue'
96-
import Icon from '@/components/icons/Icon.vue'
9789
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
9890
9991
const props = defineProps<{
@@ -124,19 +116,19 @@ const limitOptions = [
124116
{ value: 200, label: 'Top 200' },
125117
]
126118
119+
// 前三名金/银/铜徽章
120+
const RANK_BADGE_CLASSES = [
121+
'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
122+
'bg-gray-200 text-gray-600 dark:bg-gray-500/20 dark:text-gray-300',
123+
'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
124+
]
125+
127126
const items = ref<UserBreakdownItem[]>([])
128127
const loading = ref(false)
129128
const sortBy = ref<SortKey>('total_tokens')
130129
const limit = ref(50)
131-
const search = ref('')
132130
let reqSeq = 0
133131
134-
const displayItems = computed(() => {
135-
const kw = search.value.trim().toLowerCase()
136-
if (!kw) return items.value
137-
return items.value.filter((u) => (u.email || `user #${u.user_id}`).toLowerCase().includes(kw))
138-
})
139-
140132
const fmtTokens = (v: number) => formatCompactNumber(v)
141133
const fmtCost = (v: number) => formatCostFixed(v, 4)
142134

0 commit comments

Comments
 (0)