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:
shaw committed 2026-07-09 21:09:40 +08:00
1 parent e2c731abe4
commit 1a3cc2a787
14 files changed
+478 -169

No files matched your search

+118 -81
View File
@@ -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 {