mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 17:08:33 +08:00
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 嵌入模式(默认关闭不影响其他使用方)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div :class="flat ? 'p-4 sm:p-6' : 'card p-6'">
|
||||
<!-- Toolbar: left filters (multi-line) + right actions -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<!-- Left: filters (allowed to wrap to multiple rows) -->
|
||||
@@ -133,8 +133,8 @@
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Mode Filter (usage only) -->
|
||||
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<!-- Billing Mode Filter (usage only;用户排行的 user-breakdown 接口不支持该维度) -->
|
||||
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
|
||||
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<slot name="after-reset" />
|
||||
<template v-if="mode !== 'errors'">
|
||||
<template v-if="mode === 'usage'">
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
@@ -204,13 +204,19 @@ interface Props {
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
modelOptions?: string[]
|
||||
/** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */
|
||||
mode?: 'usage' | 'errors'
|
||||
/**
|
||||
* errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用)
|
||||
* ranking 模式:同 usage 但隐藏计费模式筛选与清理/导出按钮(用户排行 tab 用)
|
||||
*/
|
||||
mode?: 'usage' | 'errors' | 'ranking'
|
||||
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
|
||||
flat?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showActions: true,
|
||||
mode: 'usage'
|
||||
mode: 'usage',
|
||||
flat: false
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
@@ -485,4 +491,13 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
// 供外部(如用户排行下钻)在程序化设置 user_id 后回显选中的用户邮箱
|
||||
const setUserKeyword = (email: string) => {
|
||||
userKeyword.value = email
|
||||
userResults.value = []
|
||||
showUserDropdown.value = false
|
||||
}
|
||||
|
||||
defineExpose({ setUserKeyword })
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div :class="flat ? '' : 'card overflow-hidden'">
|
||||
<div
|
||||
v-if="showIpGeoToolbar"
|
||||
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 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-duration="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
|
||||
<!-- 合并首字/总耗时的健康度列:左侧色条上端随首字档、下端随总耗时档,中段(40%-60%)短渐变过渡,便于纵向扫视整体健康状况 -->
|
||||
<template #cell-latency="{ row }">
|
||||
<div class="flex items-stretch gap-2">
|
||||
<span
|
||||
class="w-1 shrink-0 rounded-full"
|
||||
:class="row.first_token_ms != null
|
||||
? ['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)]]
|
||||
: LATENCY_BAR_CLASSES[durationSeverity(row.duration_ms ?? 0)]"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="grid grid-cols-[max-content_max-content] items-baseline gap-x-2 gap-y-0.5 text-xs">
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ t('usage.latencyFirstToken') }}</span>
|
||||
<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>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ t('usage.latencyDuration') }}</span>
|
||||
<span class="font-medium tabular-nums" :class="LATENCY_TEXT_CLASSES[durationSeverity(row.duration_ms ?? 0)]">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
@@ -430,6 +441,14 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
|
||||
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
|
||||
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
import {
|
||||
LATENCY_BAR_CLASSES,
|
||||
LATENCY_BAR_FROM_CLASSES,
|
||||
LATENCY_BAR_TO_CLASSES,
|
||||
LATENCY_TEXT_CLASSES,
|
||||
durationSeverity,
|
||||
firstTokenSeverity,
|
||||
} from '@/utils/latencyHealth'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
getBillingModeLabel,
|
||||
@@ -474,6 +493,8 @@ interface Props {
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
showAccountBilling?: boolean
|
||||
showUpstreamEndpoint?: boolean
|
||||
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
|
||||
flat?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -482,7 +503,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
defaultSortKey: '',
|
||||
defaultSortOrder: 'asc',
|
||||
showAccountBilling: true,
|
||||
showUpstreamEndpoint: true
|
||||
showUpstreamEndpoint: true,
|
||||
flat: false
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
userClick: [userID: number, email?: string]
|
||||
@@ -552,10 +574,14 @@ const formatUserAgent = (ua: string): string => {
|
||||
return ua
|
||||
}
|
||||
|
||||
// 超过 1 分钟简化为 "Xm Ys",免去人工换算(超过 1 小时再进位为 "Xh Ym")
|
||||
const formatDuration = (ms: number | null | undefined): string => {
|
||||
if (ms == null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
if (totalSec < 3600) return `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`
|
||||
return `${Math.floor(totalSec / 3600)}h ${Math.floor((totalSec % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
// Cost tooltip functions
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">
|
||||
{{ t('admin.usage.tokenRanking.title') }}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-400">{{ t('admin.usage.tokenRanking.subtitle') }}</span>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="input h-8 w-44 text-sm"
|
||||
:placeholder="t('admin.usage.tokenRanking.searchPlaceholder')"
|
||||
/>
|
||||
<!-- 用量页"用户排行"tab 内容:无卡片外观,依赖父级统一卡片;筛选/时间范围复用页面级筛选栏 -->
|
||||
<div>
|
||||
<!-- Toolbar -->
|
||||
<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">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('admin.usage.tokenRanking.subtitle') }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="!loading && items.length > 0" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.usage.tokenRanking.userCount', { count: items.length }) }}
|
||||
</span>
|
||||
<div class="w-28">
|
||||
<Select v-model="limit" :options="limitOptions" @change="load" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary h-8 px-2"
|
||||
:title="t('common.refresh')"
|
||||
@click="load"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th class="w-10 py-2 pr-2 text-left font-medium">#</th>
|
||||
<th class="py-2 pr-2 text-left font-medium">{{ t('admin.usage.tokenRanking.columns.user') }}</th>
|
||||
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<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>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.usage.tokenRanking.columns.user') }}
|
||||
</th>
|
||||
<th
|
||||
v-for="col in sortableColumns"
|
||||
:key="col.key"
|
||||
class="cursor-pointer select-none py-2 pl-2 text-right font-medium hover:text-primary-500"
|
||||
:class="{ 'text-primary-600 dark:text-primary-400': sortBy === col.key }"
|
||||
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"
|
||||
:class="sortBy === col.key ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-dark-400'"
|
||||
@click="setSort(col.key)"
|
||||
>
|
||||
{{ t(col.label) }}
|
||||
@@ -46,54 +35,57 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<tr v-if="loading">
|
||||
<td :colspan="sortableColumns.length + 2" class="py-8 text-center">
|
||||
<td :colspan="sortableColumns.length + 2" class="py-12 text-center">
|
||||
<LoadingSpinner />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="displayItems.length === 0">
|
||||
<td :colspan="sortableColumns.length + 2" class="py-8 text-center text-sm text-gray-400">
|
||||
<tr v-else-if="items.length === 0">
|
||||
<td :colspan="sortableColumns.length + 2" class="py-12 text-center text-sm text-gray-400">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(item, index) in displayItems"
|
||||
v-for="(item, index) in items"
|
||||
v-else
|
||||
:key="item.user_id"
|
||||
class="cursor-pointer border-b border-gray-50 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-dark-700/40"
|
||||
class="cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/40"
|
||||
:title="t('admin.usage.tokenRanking.rowHint')"
|
||||
@click="$emit('select-user', item.user_id, item.email)"
|
||||
>
|
||||
<td class="py-2 pr-2 text-gray-400">{{ index + 1 }}</td>
|
||||
<td class="max-w-[220px] truncate py-2 pr-2 text-gray-700 dark:text-gray-200" :title="item.email">
|
||||
{{ item.email || `User #${item.user_id}` }}
|
||||
<td class="px-4 py-3 sm:px-6">
|
||||
<span
|
||||
v-if="index < 3"
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold"
|
||||
:class="RANK_BADGE_CLASSES[index]"
|
||||
>{{ index + 1 }}</span>
|
||||
<span v-else class="inline-block w-6 text-center text-sm tabular-nums text-gray-400">{{ index + 1 }}</span>
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ item.requests.toLocaleString() }}</td>
|
||||
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.input_tokens) }}</td>
|
||||
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.output_tokens) }}</td>
|
||||
<td class="py-2 pl-2 text-right text-gray-500 dark:text-gray-400">{{ fmtTokens(item.cache_tokens) }}</td>
|
||||
<td class="py-2 pl-2 text-right font-medium text-gray-800 dark:text-gray-100">{{ fmtTokens(item.total_tokens) }}</td>
|
||||
<td class="py-2 pl-2 text-right text-green-600 dark:text-green-400">${{ fmtCost(item.actual_cost) }}</td>
|
||||
<td class="max-w-[260px] truncate px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-200" :title="item.email">
|
||||
{{ item.email || `User #${item.user_id}` }}
|
||||
<span class="ml-1 font-normal text-gray-400 dark:text-gray-500">#{{ item.user_id }}</span>
|
||||
</td>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="!loading && items.length > 0" class="mt-2 text-right text-xs text-gray-400">
|
||||
{{ t('admin.usage.tokenRanking.userCount', { count: displayItems.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getUserBreakdown, type UserBreakdownParams } from '@/api/admin/dashboard'
|
||||
import { formatCompactNumber, formatCostFixed } from '@/utils/format'
|
||||
import type { UserBreakdownItem } from '@/types'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -124,19 +116,19 @@ const limitOptions = [
|
||||
{ value: 200, label: 'Top 200' },
|
||||
]
|
||||
|
||||
// 前三名金/银/铜徽章
|
||||
const RANK_BADGE_CLASSES = [
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
|
||||
'bg-gray-200 text-gray-600 dark:bg-gray-500/20 dark:text-gray-300',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
|
||||
]
|
||||
|
||||
const items = ref<UserBreakdownItem[]>([])
|
||||
const loading = ref(false)
|
||||
const sortBy = ref<SortKey>('total_tokens')
|
||||
const limit = ref(50)
|
||||
const search = ref('')
|
||||
let reqSeq = 0
|
||||
|
||||
const displayItems = computed(() => {
|
||||
const kw = search.value.trim().toLowerCase()
|
||||
if (!kw) return items.value
|
||||
return items.value.filter((u) => (u.email || `user #${u.user_id}`).toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const fmtTokens = (v: number) => formatCompactNumber(v)
|
||||
const fmtCost = (v: number) => formatCostFixed(v, 4)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
import UserTokenRanking from '../UserTokenRanking.vue'
|
||||
|
||||
const getUserBreakdown = vi.fn()
|
||||
|
||||
vi.mock('@/api/admin/dashboard', () => ({
|
||||
getUserBreakdown: (...args: unknown[]) => getUserBreakdown(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const item = (id: number, tokens: number) => ({
|
||||
user_id: id,
|
||||
email: `u${id}@test.com`,
|
||||
requests: 1,
|
||||
input_tokens: tokens,
|
||||
output_tokens: 0,
|
||||
cache_tokens: 0,
|
||||
total_tokens: tokens,
|
||||
actual_cost: 0.5,
|
||||
})
|
||||
|
||||
const mountRanking = (props: Record<string, unknown> = {}) =>
|
||||
mount(UserTokenRanking, {
|
||||
props: {
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-08',
|
||||
filters: {},
|
||||
...props,
|
||||
},
|
||||
global: { stubs: { Select: true, LoadingSpinner: true } },
|
||||
})
|
||||
|
||||
describe('UserTokenRanking', () => {
|
||||
beforeEach(() => {
|
||||
getUserBreakdown.mockReset()
|
||||
getUserBreakdown.mockResolvedValue({ users: [item(1, 100), item(2, 50)] })
|
||||
})
|
||||
|
||||
it('loads on mount with shared filters and emits select-user with id + email on row click', async () => {
|
||||
const wrapper = mountRanking({ filters: { group_id: 3 }, model: 'claude-fable-5' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getUserBreakdown).toHaveBeenCalledWith(expect.objectContaining({
|
||||
group_id: 3,
|
||||
model: 'claude-fable-5',
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-08',
|
||||
sort_by: 'total_tokens',
|
||||
limit: 50,
|
||||
}))
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
await rows[0].trigger('click')
|
||||
expect(wrapper.emitted('select-user')![0]).toEqual([1, 'u1@test.com'])
|
||||
})
|
||||
|
||||
it('reloads when shared filters change', async () => {
|
||||
const wrapper = mountRanking()
|
||||
await flushPromises()
|
||||
expect(getUserBreakdown).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.setProps({ filters: { user_id: 9 } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getUserBreakdown).toHaveBeenCalledTimes(2)
|
||||
expect(getUserBreakdown).toHaveBeenLastCalledWith(expect.objectContaining({ user_id: 9 }))
|
||||
})
|
||||
})
|
||||
@@ -484,9 +484,8 @@ export default {
|
||||
failedToLoadUser: 'Failed to load user info',
|
||||
userDeletedBadge: 'Deleted',
|
||||
tokenRanking: {
|
||||
title: 'User Token Ranking',
|
||||
subtitle: 'Per-user token usage for the current filters and time range; click a row to drill down',
|
||||
searchPlaceholder: 'Search user email...',
|
||||
subtitle: 'Per-user token usage for the current filters and time range',
|
||||
rowHint: "Click to view this user's usage details",
|
||||
userCount: '{count} users',
|
||||
columns: {
|
||||
user: 'User',
|
||||
|
||||
@@ -289,6 +289,9 @@ export default {
|
||||
cost: 'Cost',
|
||||
firstToken: 'First Token',
|
||||
duration: 'Duration',
|
||||
latency: 'Latency',
|
||||
latencyFirstToken: 'First',
|
||||
latencyDuration: 'Total',
|
||||
time: 'Time',
|
||||
ws: 'WS',
|
||||
stream: 'Stream',
|
||||
@@ -356,7 +359,7 @@ export default {
|
||||
detailAccuracy: 'Accuracy',
|
||||
detailCoordinates: 'Coordinates',
|
||||
},
|
||||
tabs: { usage: 'Usage', errors: 'Error Requests' },
|
||||
tabs: { usage: 'Usage', errors: 'Error Requests', ranking: 'User Ranking' },
|
||||
errors: {
|
||||
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
|
||||
category: 'Category', platform: 'Platform', message: 'Message',
|
||||
|
||||
@@ -545,9 +545,8 @@ export default {
|
||||
failedToLoadUser: '加载用户信息失败',
|
||||
userDeletedBadge: '已删除',
|
||||
tokenRanking: {
|
||||
title: '用户 Token 排行',
|
||||
subtitle: '按当前筛选与时间范围统计每个用户的 Token 用量,点击行可下钻到该用户',
|
||||
searchPlaceholder: '搜索用户邮箱...',
|
||||
subtitle: '按当前筛选与时间范围统计每个用户的 Token 用量',
|
||||
rowHint: '点击查看该用户的用量明细',
|
||||
userCount: '共 {count} 位用户',
|
||||
columns: {
|
||||
user: '用户',
|
||||
|
||||
@@ -294,6 +294,9 @@ export default {
|
||||
cost: '费用',
|
||||
firstToken: '首 Token',
|
||||
duration: '耗时',
|
||||
latency: '延迟',
|
||||
latencyFirstToken: '首字',
|
||||
latencyDuration: '总耗时',
|
||||
time: '时间',
|
||||
ws: 'WS',
|
||||
stream: '流式',
|
||||
@@ -361,7 +364,7 @@ export default {
|
||||
detailAccuracy: '定位精度',
|
||||
detailCoordinates: '坐标',
|
||||
},
|
||||
tabs: { usage: '用量明细', errors: '错误请求' },
|
||||
tabs: { usage: '用量明细', errors: '错误请求', ranking: '用户排行' },
|
||||
errors: {
|
||||
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
|
||||
category: '分类', platform: '平台', message: '错误信息',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { durationSeverity, firstTokenSeverity } from '../latencyHealth'
|
||||
|
||||
describe('latencyHealth', () => {
|
||||
it('classifies first-token latency at 10s/30s/60s boundaries', () => {
|
||||
expect(firstTokenSeverity(0)).toBe('good')
|
||||
expect(firstTokenSeverity(9_999)).toBe('good')
|
||||
expect(firstTokenSeverity(10_000)).toBe('warn')
|
||||
expect(firstTokenSeverity(29_999)).toBe('warn')
|
||||
expect(firstTokenSeverity(30_000)).toBe('slow')
|
||||
expect(firstTokenSeverity(59_999)).toBe('slow')
|
||||
expect(firstTokenSeverity(60_000)).toBe('critical')
|
||||
})
|
||||
|
||||
it('classifies total duration at 1min/3min/5min boundaries', () => {
|
||||
expect(durationSeverity(0)).toBe('good')
|
||||
expect(durationSeverity(59_999)).toBe('good')
|
||||
expect(durationSeverity(60_000)).toBe('warn')
|
||||
expect(durationSeverity(179_999)).toBe('warn')
|
||||
expect(durationSeverity(180_000)).toBe('slow')
|
||||
expect(durationSeverity(299_999)).toBe('slow')
|
||||
expect(durationSeverity(300_000)).toBe('critical')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 请求延迟健康度分档(用于用量明细"延迟"列的纵向健康扫视)。
|
||||
*
|
||||
* 首 Token(TTFT):10s 内正常,10-30s 偏慢,30-60s 缓慢,60s 及以上严重。
|
||||
* 总耗时:流式请求整体时长天然更长,阈值放宽为 1min / 3min / 5min。
|
||||
*/
|
||||
export type LatencySeverity = 'good' | 'warn' | 'slow' | 'critical'
|
||||
|
||||
export const FIRST_TOKEN_THRESHOLDS_MS = {
|
||||
warn: 10_000,
|
||||
slow: 30_000,
|
||||
critical: 60_000,
|
||||
} as const
|
||||
|
||||
export const DURATION_THRESHOLDS_MS = {
|
||||
warn: 60_000,
|
||||
slow: 180_000,
|
||||
critical: 300_000,
|
||||
} as const
|
||||
|
||||
interface Thresholds {
|
||||
warn: number
|
||||
slow: number
|
||||
critical: number
|
||||
}
|
||||
|
||||
const classify = (ms: number, thresholds: Thresholds): LatencySeverity => {
|
||||
if (ms >= thresholds.critical) return 'critical'
|
||||
if (ms >= thresholds.slow) return 'slow'
|
||||
if (ms >= thresholds.warn) return 'warn'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
export const firstTokenSeverity = (ms: number): LatencySeverity =>
|
||||
classify(ms, FIRST_TOKEN_THRESHOLDS_MS)
|
||||
|
||||
export const durationSeverity = (ms: number): LatencySeverity =>
|
||||
classify(ms, DURATION_THRESHOLDS_MS)
|
||||
|
||||
export const LATENCY_TEXT_CLASSES: Record<LatencySeverity, string> = {
|
||||
good: 'text-emerald-600 dark:text-emerald-400',
|
||||
warn: 'text-amber-600 dark:text-amber-400',
|
||||
slow: 'text-orange-600 dark:text-orange-400',
|
||||
critical: 'text-red-600 dark:text-red-400',
|
||||
}
|
||||
|
||||
/** 无首字数据时的纯色色条(仅按总耗时档着色)。 */
|
||||
export const LATENCY_BAR_CLASSES: Record<LatencySeverity, string> = {
|
||||
good: 'bg-emerald-500',
|
||||
warn: 'bg-amber-400',
|
||||
slow: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
/** 渐变色条上端(首字档);与 LATENCY_BAR_TO_CLASSES 组合成上下渐变,避免两段硬切割裂感。 */
|
||||
export const LATENCY_BAR_FROM_CLASSES: Record<LatencySeverity, string> = {
|
||||
good: 'from-emerald-500',
|
||||
warn: 'from-amber-400',
|
||||
slow: 'from-orange-500',
|
||||
critical: 'from-red-500',
|
||||
}
|
||||
|
||||
/** 渐变色条下端(总耗时档)。 */
|
||||
export const LATENCY_BAR_TO_CLASSES: Record<LatencySeverity, string> = {
|
||||
good: 'to-emerald-500',
|
||||
warn: 'to-amber-400',
|
||||
slow: 'to-orange-500',
|
||||
critical: 'to-red-500',
|
||||
}
|
||||
@@ -64,86 +64,104 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" :mode="activeTab === 'errors' ? 'errors' : 'usage'" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<template #after-reset>
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<!-- 明细区:tab 栏 + 筛选 + 内容收进同一张卡片,消除割裂感 -->
|
||||
<div class="card">
|
||||
<div class="flex flex-wrap items-center border-b border-gray-200 px-2 dark:border-dark-700 sm:px-4">
|
||||
<button
|
||||
v-for="tab in detailTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
data-testid="usage-detail-tab"
|
||||
class="-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-3 text-sm font-medium transition-colors sm:px-4"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-dark-500 dark:hover:text-gray-200'"
|
||||
@click="switchTab(tab.key)"
|
||||
>
|
||||
<Icon :name="tab.icon" size="sm" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UsageFilters v-model="filters" ref="usageFiltersRef" flat :mode="activeTab" class="border-b border-gray-100 dark:border-dark-700/50" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<template #after-reset>
|
||||
<div v-if="activeTab !== 'ranking'" class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
v-for="col in currentToggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleCurrentColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isCurrentColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in currentToggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleCurrentColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isCurrentColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UsageFilters>
|
||||
<div class="mb-4 flex gap-2 border-b border-gray-200 dark:border-dark-700">
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
|
||||
{{ t('usage.tabs.usage') }}
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrorsTab">
|
||||
{{ t('usage.tabs.errors') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'usage'">
|
||||
<UserTokenRanking
|
||||
class="mb-4"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:filters="breakdownFilters"
|
||||
:model="filters.model"
|
||||
@select-user="handleRankingSelectUser"
|
||||
/>
|
||||
<UsageTable
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
<div v-show="activeTab === 'errors'">
|
||||
<OpsErrorLogTable
|
||||
:rows="errRows" :total="errTotal" :loading="errLoading"
|
||||
:page="errPage" :page-size="errPageSize"
|
||||
:visible-column-keys="errVisibleColumnKeys"
|
||||
user-clickable
|
||||
@userClick="handleUserClick"
|
||||
@openErrorDetail="openError"
|
||||
@sort="onErrSort"
|
||||
@update:page="onErrPage"
|
||||
@update:pageSize="onErrPageSize"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed" />
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
|
||||
</template>
|
||||
</UsageFilters>
|
||||
|
||||
<div v-show="activeTab === 'usage'" class="overflow-hidden rounded-b-2xl">
|
||||
<UsageTable
|
||||
flat
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
<div v-show="activeTab === 'errors'" class="overflow-hidden rounded-b-2xl">
|
||||
<OpsErrorLogTable
|
||||
flat
|
||||
:rows="errRows" :total="errTotal" :loading="errLoading"
|
||||
:page="errPage" :page-size="errPageSize"
|
||||
:visible-column-keys="errVisibleColumnKeys"
|
||||
user-clickable
|
||||
@userClick="handleUserClick"
|
||||
@openErrorDetail="openError"
|
||||
@sort="onErrSort"
|
||||
@update:page="onErrPage"
|
||||
@update:pageSize="onErrPageSize"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed" />
|
||||
</div>
|
||||
<!-- 懒挂载:首次切到该 tab 才请求排行数据,之后随筛选自动刷新 -->
|
||||
<div v-if="rankingMounted" v-show="activeTab === 'ranking'" class="overflow-hidden rounded-b-2xl">
|
||||
<UserTokenRanking
|
||||
ref="rankingRef"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:filters="breakdownFilters"
|
||||
:model="filters.model"
|
||||
@select-user="handleRankingSelectUser"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
|
||||
@@ -244,9 +262,12 @@ const handleUserClick = async (userId: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Drill down from the per-user token ranking: scope the whole usage view to that user.
|
||||
const handleRankingSelectUser = (userId: number) => {
|
||||
// Drill down from the per-user token ranking: scope the whole usage view to
|
||||
// that user and jump to the usage-detail tab so the drill-down is visible.
|
||||
const handleRankingSelectUser = (userId: number, email: string) => {
|
||||
filters.value = { ...filters.value, user_id: userId }
|
||||
usageFiltersRef.value?.setUserKeyword?.(email || '')
|
||||
activeTab.value = 'usage'
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
@@ -490,6 +511,7 @@ const refreshData = () => {
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
if (activeTab.value === 'errors') loadAdminErrors()
|
||||
if (rankingMounted.value) rankingRef.value?.reload()
|
||||
}
|
||||
const resetFilters = () => {
|
||||
const range = getLast24HoursRangeDates()
|
||||
@@ -595,8 +617,7 @@ const allColumns = computed(() => [
|
||||
{ key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'latency', label: t('usage.latency'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||
@@ -716,8 +737,25 @@ const loadSavedColumns = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Detail tabs
|
||||
type DetailTab = 'usage' | 'errors' | 'ranking'
|
||||
const activeTab = ref<DetailTab>('usage')
|
||||
const detailTabs = computed(() => [
|
||||
{ key: 'usage' as const, label: t('usage.tabs.usage'), icon: 'document' as const },
|
||||
{ key: 'errors' as const, label: t('usage.tabs.errors'), icon: 'exclamationTriangle' as const },
|
||||
{ key: 'ranking' as const, label: t('usage.tabs.ranking'), icon: 'chart' as const },
|
||||
])
|
||||
const usageFiltersRef = ref<InstanceType<typeof UsageFilters> | null>(null)
|
||||
const rankingMounted = ref(false)
|
||||
const rankingRef = ref<InstanceType<typeof UserTokenRanking> | null>(null)
|
||||
|
||||
const switchTab = (tab: DetailTab) => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'errors' && errRows.value.length === 0) loadAdminErrors()
|
||||
if (tab === 'ranking') rankingMounted.value = true
|
||||
}
|
||||
|
||||
// Error tab state
|
||||
const activeTab = ref<'usage' | 'errors'>('usage')
|
||||
const errRows = ref<OpsErrorLog[]>([])
|
||||
const errLoading = ref(false)
|
||||
const errPage = ref(1)
|
||||
@@ -771,7 +809,6 @@ const onErrSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
|
||||
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
|
||||
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
|
||||
const switchToErrorsTab = () => { activeTab.value = 'errors'; if (errRows.value.length === 0) loadAdminErrors() }
|
||||
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -95,6 +95,10 @@ const UsageTableStub = {
|
||||
emits: ['userClick'],
|
||||
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
|
||||
}
|
||||
const UserTokenRankingStub = {
|
||||
emits: ['select-user'],
|
||||
template: '<div data-test="ranking"><button class="pick-user" @click="$emit(\'select-user\', 5, \'rank@test.com\')">pick</button></div>',
|
||||
}
|
||||
const ModelDistributionChartStub = {
|
||||
props: ['metric'],
|
||||
emits: ['update:metric'],
|
||||
@@ -341,8 +345,8 @@ describe('admin UsageView errors tab filter forwarding', () => {
|
||||
vm.filters.group_id = 3
|
||||
await flushPromises()
|
||||
|
||||
// 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors
|
||||
const tabs = wrapper.findAll('button.tab')
|
||||
// 切换到「错误请求」标签(第二个 tab 按钮)触发 loadAdminErrors
|
||||
const tabs = wrapper.findAll('[data-testid="usage-detail-tab"]')
|
||||
await tabs[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -354,3 +358,58 @@ describe('admin UsageView errors tab filter forwarding', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView ranking tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReset()
|
||||
getStats.mockReset()
|
||||
getSnapshotV2.mockReset()
|
||||
getModelStats.mockReset()
|
||||
|
||||
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
getStats.mockResolvedValue({
|
||||
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
|
||||
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
|
||||
})
|
||||
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
|
||||
getModelStats.mockResolvedValue({ models: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('mounts ranking lazily and drill-down sets user filter then jumps back to usage tab', async () => {
|
||||
const wrapper = mount(UsageView, {
|
||||
global: { stubs: {
|
||||
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
|
||||
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true, Pagination: true, Select: true,
|
||||
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
|
||||
ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true,
|
||||
UserTokenRanking: UserTokenRankingStub, OpsErrorLogTable: true, OpsErrorDetailModal: true,
|
||||
} },
|
||||
})
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
// 懒挂载:切到排行 tab 前不渲染
|
||||
expect(wrapper.find('[data-test="ranking"]').exists()).toBe(false)
|
||||
|
||||
const tabs = wrapper.findAll('[data-testid="usage-detail-tab"]')
|
||||
expect(tabs).toHaveLength(3)
|
||||
await tabs[2].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="ranking"]').exists()).toBe(true)
|
||||
|
||||
// 下钻:设置 user_id、切回用量明细 tab 并按新筛选重新拉取列表
|
||||
list.mockClear()
|
||||
await wrapper.find('[data-test="ranking"] .pick-user').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((wrapper.vm as any).activeTab).toBe('usage')
|
||||
expect((wrapper.vm as any).filters.user_id).toBe(5)
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 5 }), expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="card flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden" :class="flat ? '' : 'card'">
|
||||
<IpGeoBatchToolbar :ips="rows.map((r) => r.client_ip)" @failed="emit('ipGeoBatchFailed')" />
|
||||
|
||||
<DataTable
|
||||
@@ -298,6 +298,8 @@ interface Props {
|
||||
userClickable?: boolean
|
||||
/** 列设置:仅显示这些 key 的列;不传则全量 */
|
||||
visibleColumnKeys?: string[]
|
||||
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
|
||||
flat?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
|
||||
@@ -709,8 +709,7 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'latency', label: t('usage.latency'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user