chore: resolve pr 377 checks

This commit is contained in:
fawney19
2026-05-06 02:22:32 +08:00
502 changed files with 89553 additions and 21893 deletions

View File

@@ -22,21 +22,6 @@
<SelectItem value="last90days">
最近90天
</SelectItem>
<SelectItem value="this_week">
本周
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
</SelectItem>
<SelectItem value="custom">
自定义
</SelectItem>
@@ -110,7 +95,20 @@ const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams]
}>()
const selectedPreset = ref(props.modelValue.preset || 'last7days')
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
type SelectablePreset = typeof selectablePresets[number]
function normalizePreset(value: DateRangeParams): SelectablePreset {
if (value.preset && selectablePresets.includes(value.preset as SelectablePreset)) {
return value.preset as SelectablePreset
}
if (!value.preset && (value.start_date || value.end_date)) {
return 'custom'
}
return 'last7days'
}
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day')
@@ -162,7 +160,7 @@ function getValueKey(value: DateRangeParams): string {
}
watch(() => props.modelValue, (value) => {
if (value.preset) selectedPreset.value = value.preset
selectedPreset.value = normalizePreset(value)
if (value.start_date !== undefined) startDate.value = value.start_date || ''
if (value.end_date !== undefined) endDate.value = value.end_date || ''
if (value.granularity) selectedGranularity.value = value.granularity

View File

@@ -255,7 +255,14 @@
{{ getStreamModeLabel(record) }}
</Badge>
<span class="text-muted-foreground/50">|</span>
<span>{{ formatDateTime(record.created_at) }}</span>
<div class="flex flex-col leading-tight tabular-nums">
<span class="text-[11px] text-foreground whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
</span>
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ formatRecordDate(record.created_at) }}
</span>
</div>
<template v-if="record.api_format">
<span class="text-muted-foreground/50">|</span>
<span>{{ formatApiFormat(record.api_format) }}</span>
@@ -273,15 +280,8 @@
/></span>
<span
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end tabular-nums leading-3 shrink-0"
:title="getRecordPerformanceTitle(record)"
>
<span class="whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span
v-if="getRecordDisplayOutputRate(record) != null"
class="text-primary/80 whitespace-nowrap"
>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</span>
class="tabular-nums"
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ record.response_time_ms != null ? (record.response_time_ms / 1000).toFixed(1) : '-' }}{{ record.response_time_ms != null ? 's' : '' }}</span>
<span
v-else
class="tabular-nums"
@@ -295,36 +295,36 @@
</div>
<!-- 桌面端表格视图 -->
<Table :class="isAdmin ? 'hidden md:table table-fixed min-w-[1304px]' : 'hidden md:table table-fixed min-w-[1218px]'">
<Table class="hidden md:table table-fixed w-full">
<colgroup v-if="isAdmin">
<col class="w-[88px]">
<col class="w-[180px]">
<col class="w-[190px]">
<col class="w-[160px]">
<col class="w-[190px]">
<col class="w-[108px]">
<col class="w-[150px]">
<col class="w-[108px]">
<col class="w-[130px]">
<col class="w-[8%]">
<col class="w-[12%]">
<col class="w-[20%]">
<col class="w-[23%]">
<col class="w-[12%]">
<col class="w-[5%]">
<col class="w-[9%]">
<col class="w-[5%]">
<col class="w-[6%]">
</colgroup>
<colgroup v-else>
<col class="w-[88px]">
<col class="w-[200px]">
<col class="w-[220px]">
<col class="w-[190px]">
<col class="w-[120px]">
<col class="w-[150px]">
<col class="w-[120px]">
<col class="w-[130px]">
<col class="w-[9%]">
<col class="w-[17%]">
<col class="w-[26%]">
<col class="w-[15%]">
<col class="w-[7%]">
<col class="w-[11%]">
<col class="w-[7%]">
<col class="w-[8%]">
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold w-[70px]">
<TableHead class="h-12 font-semibold w-[8%]">
时间
</TableHead>
<SortableTableHead
v-if="isAdmin"
class="h-12 font-semibold w-[100px]"
class="h-12 font-semibold w-[12%]"
column-key="user"
:sortable="false"
:filter-active="filterUser !== '__all__'"
@@ -343,12 +343,12 @@
</SortableTableHead>
<TableHead
v-if="!isAdmin"
class="h-12 font-semibold w-[100px]"
class="h-12 font-semibold w-[17%]"
>
密钥
</TableHead>
<SortableTableHead
class="h-12 font-semibold w-[140px]"
class="h-12 font-semibold w-[20%]"
column-key="model"
:sortable="false"
:filter-active="filterModel !== '__all__'"
@@ -367,7 +367,7 @@
</SortableTableHead>
<SortableTableHead
v-if="isAdmin"
class="h-12 font-semibold w-[100px]"
class="h-12 font-semibold w-[23%]"
column-key="provider"
:sortable="false"
:filter-active="filterProvider !== '__all__'"
@@ -385,7 +385,7 @@
</template>
</SortableTableHead>
<SortableTableHead
class="h-12 font-semibold w-[120px]"
class="h-12 font-semibold w-[12%]"
column-key="api_format"
:sortable="false"
:filter-active="filterApiFormat !== '__all__'"
@@ -403,7 +403,7 @@
</template>
</SortableTableHead>
<SortableTableHead
class="h-12 font-semibold w-[108px] text-center"
class="h-12 font-semibold w-[5%] text-center"
column-key="status"
:sortable="false"
align="center"
@@ -421,16 +421,16 @@
/>
</template>
</SortableTableHead>
<TableHead class="h-12 font-semibold w-[140px] text-right">
<TableHead class="h-12 font-semibold w-[9%] text-center">
Tokens
</TableHead>
<TableHead class="h-12 font-semibold w-[108px] text-right">
<TableHead class="h-12 font-semibold w-[5%] text-right">
费用
</TableHead>
<TableHead class="h-12 font-semibold w-[130px] text-right">
<TableHead class="h-12 font-semibold w-[6%] text-right">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
<span>首字</span>
<span class="text-muted-foreground font-normal">总耗时</span>
</div>
</TableHead>
</TableRow>
@@ -452,12 +452,19 @@
@mousedown="handleRowMouseDown($event, record.id)"
@click="handleRowClick($event, record.id)"
>
<TableCell class="text-xs py-4 w-[70px]">
{{ formatDateTime(record.created_at) }}
<TableCell class="py-4 w-[8%] align-top">
<div class="flex flex-col gap-0.5 leading-tight">
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
</span>
<span class="text-[11px] text-muted-foreground tabular-nums whitespace-nowrap">
{{ formatRecordDate(record.created_at) }}
</span>
</div>
</TableCell>
<TableCell
v-if="isAdmin"
class="py-4 w-[100px] truncate"
class="py-4 w-[12%] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
>
<div class="flex flex-col text-xs gap-0.5">
@@ -476,7 +483,7 @@
<!-- 用户页面的密钥列 -->
<TableCell
v-if="!isAdmin"
class="py-4 w-[100px]"
class="py-4 w-[17%]"
:title="record.api_key?.name || '-'"
>
<div class="flex flex-col text-xs gap-0.5">
@@ -490,7 +497,7 @@
</div>
</TableCell>
<TableCell
class="font-medium py-4 w-[140px]"
class="font-medium py-4 w-[20%]"
:title="getModelTooltip(record)"
>
<div
@@ -521,11 +528,11 @@
</TableCell>
<TableCell
v-if="isAdmin"
class="py-4 w-[60px]"
class="py-4 w-[23%]"
>
<div class="flex items-center gap-1">
<div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span>
<div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 flex-col text-xs gap-0.5">
<span class="truncate">{{ record.provider }}</span>
<span
v-if="record.provider_key_name"
class="text-muted-foreground truncate"
@@ -577,7 +584,7 @@
</div>
</TableCell>
<TableCell
class="py-4 w-[120px]"
class="py-4 w-[12%]"
:title="getApiFormatTooltip(record)"
>
<!-- 有格式转换或同族格式差异两行显示 -->
@@ -612,7 +619,7 @@
class="text-muted-foreground text-xs"
>-</span>
</TableCell>
<TableCell class="text-center py-4 w-[108px]">
<TableCell class="text-center py-4 w-[5%]">
<!-- 优先显示请求状态 -->
<Badge
v-if="getDisplayStatus(record) === 'pending'"
@@ -663,21 +670,41 @@
{{ getStreamModeLabel(record) }}
</Badge>
</TableCell>
<TableCell class="text-right py-4 w-[140px]">
<div class="flex flex-col items-end text-xs gap-0.5">
<div class="flex items-center gap-1">
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}</span>
<span class="text-muted-foreground">/</span>
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span :class="hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''">{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}</span>
<span>/</span>
<span :class="hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''">{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}</span>
</div>
<TableCell class="py-4 w-[9%]">
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
</span>
<span class="justify-self-center text-muted-foreground">
/
</span>
<span class="justify-self-start whitespace-nowrap text-left">
{{ formatTokens(record.output_tokens || 0) }}
</span>
</div>
<div class="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums text-muted-foreground">
<span
:class="[
'justify-self-end whitespace-nowrap text-right',
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}
</span>
<span class="justify-self-center">
/
</span>
<span
:class="[
'justify-self-start whitespace-nowrap text-left',
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[108px]">
<TableCell class="text-right py-4 w-[5%]">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
@@ -688,7 +715,7 @@
</span>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[130px]">
<TableCell class="text-right py-4 w-[6%]">
<!-- pending 状态只显示增长的总时间 -->
<div
v-if="getDisplayStatus(record) === 'pending'"
@@ -724,13 +751,23 @@
<div
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5"
:title="getRecordPerformanceTitle(record)"
>
<span class="tabular-nums whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span
v-if="getRecordDisplayOutputRate(record) != null"
class="text-primary/80 tabular-nums whitespace-nowrap"
>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
v-if="record.first_byte_time_ms != null"
class="tabular-nums"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span
v-if="record.response_time_ms != null"
class="text-muted-foreground tabular-nums"
>{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
</div>
<span
v-else
@@ -782,15 +819,7 @@ import {
} from '@/components/ui'
import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatDurationMs,
formatOutputRate,
getDisplayOutputRate,
getGenerationTimeMs,
isOutputRateUsingGenerationTime,
} from '../performance'
import {
formatUsageStreamLabel,
isUsageRecordFailed,
@@ -943,6 +972,27 @@ function getUpstreamStream(record: UsageRecord): boolean {
return isUsageUpstreamStream(record)
}
function parseRecordDateTime(dateStr: string): Date {
const utcDateStr = dateStr.includes('Z') || dateStr.includes('+') ? dateStr : `${dateStr}Z`
return new Date(utcDateStr)
}
function formatRecordDate(dateStr: string): string {
const date = parseRecordDateTime(dateStr)
const year = String(date.getFullYear())
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatRecordTime(dateStr: string): string {
const date = parseRecordDateTime(dateStr)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
localSearch.value = value
@@ -982,27 +1032,6 @@ function getRecordCacheCreationTokens(record: UsageRecord): number {
return getCacheCreationTokens(record)
}
function getRecordDisplayOutputRate(record: UsageRecord): number | null {
return getDisplayOutputRate(record)
}
function formatRecordLatencyPair(record: UsageRecord): string {
const firstByte = formatDurationMs(record.first_byte_time_ms)
const total = formatDurationMs(record.response_time_ms)
return `${firstByte} / ${total}`
}
function getRecordPerformanceTitle(record: UsageRecord): string {
const outputRate = getRecordDisplayOutputRate(record)
const outputRateBasis = isOutputRateUsingGenerationTime(record) ? '首字后生成耗时' : '总耗时'
return [
`首字: ${formatDurationMs(record.first_byte_time_ms)}`,
`总耗时: ${formatDurationMs(record.response_time_ms)}`,
`生成耗时: ${formatDurationMs(getGenerationTimeMs(record))}`,
`输出速度: ${formatOutputRate(outputRate)} (${outputRateBasis})`,
].join('\n')
}
function hasPositiveTokens(value: number | null | undefined): boolean {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const {
getAllUsageRecordsMock,
getUsageStatsMock,
getUsageByModelMock,
getUsageByProviderMock,
getUsageByApiFormatMock,
meGetUsageMock,
} = vi.hoisted(() => ({
getAllUsageRecordsMock: vi.fn(),
getUsageStatsMock: vi.fn(),
getUsageByModelMock: vi.fn(),
getUsageByProviderMock: vi.fn(),
getUsageByApiFormatMock: vi.fn(),
meGetUsageMock: vi.fn(),
}))
vi.mock('@/api/usage', () => ({
usageApi: {
getAllUsageRecords: getAllUsageRecordsMock,
getUsageStats: getUsageStatsMock,
getUsageByModel: getUsageByModelMock,
getUsageByProvider: getUsageByProviderMock,
getUsageByApiFormat: getUsageByApiFormatMock,
},
}))
vi.mock('@/api/me', () => ({
meApi: {
getUsage: meGetUsageMock,
},
}))
vi.mock('@/utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
http: vi.fn(),
performance: vi.fn(),
},
}))
import { useUsageData } from '../useUsageData'
import type { UsageRecord } from '../../types'
function buildUsageRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
return {
id: 'usage-1',
model: 'gpt-5',
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
cost: 0.01,
is_stream: false,
created_at: '2026-05-01T00:00:00Z',
status: 'completed',
...overrides,
}
}
describe('useUsageData', () => {
beforeEach(() => {
vi.clearAllMocks()
getAllUsageRecordsMock.mockResolvedValue({
records: [buildUsageRecord()],
total: 1,
limit: 20,
offset: 0,
})
getUsageStatsMock.mockRejectedValue({
response: { status: 500 },
message: 'stats failed',
})
getUsageByModelMock.mockResolvedValue([])
getUsageByProviderMock.mockResolvedValue([])
getUsageByApiFormatMock.mockResolvedValue([])
meGetUsageMock.mockResolvedValue({})
})
it('keeps admin records when stats refresh fails', async () => {
const isAdminPage = ref(true)
const { loadRecords, loadStats, currentRecords, totalRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value).toHaveLength(1)
expect(totalRecords.value).toBe(1)
await loadStats(dateRange)
expect(currentRecords.value).toHaveLength(1)
expect(currentRecords.value[0]).toMatchObject({
id: 'usage-1',
model: 'gpt-5',
})
expect(totalRecords.value).toBe(1)
})
it('continues loading admin breakdowns when the summary request fails', async () => {
const isAdminPage = ref(true)
const {
loadStats,
stats,
modelStats,
providerStats,
apiFormatStats,
availableModels,
availableProviders,
} = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockRejectedValueOnce({
response: { status: 500 },
message: 'summary failed',
})
getUsageByModelMock.mockResolvedValueOnce([
{
model: 'gpt-5',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
},
])
getUsageByProviderMock.mockResolvedValueOnce([
{
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 1,
},
])
getUsageByApiFormatMock.mockResolvedValueOnce([
{
api_format: 'openai:chat',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
},
])
const hadFailure = await loadStats(dateRange)
expect(hadFailure).toBe(true)
expect(stats.value).toMatchObject({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
})
expect(modelStats.value).toHaveLength(1)
expect(providerStats.value).toHaveLength(1)
expect(apiFormatStats.value).toHaveLength(1)
expect(availableModels.value).toEqual(['gpt-5'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
})

View File

@@ -70,7 +70,7 @@ export function useUsageData(options: UseUsageDataOptions) {
})
// 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) {
async function loadStats(dateRange?: DateRangeParams): Promise<boolean> {
const requestId = ++loadStatsRequestId
isLoadingStats.value = true
currentDateRange.value = dateRange
@@ -78,182 +78,227 @@ export function useUsageData(options: UseUsageDataOptions) {
try {
if (isAdminPage.value) {
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
const statsData = await usageApi.getUsageStats(dateRange)
stats.value = createDefaultStats()
modelStats.value = []
providerStats.value = []
apiFormatStats.value = []
availableModels.value = []
availableProviders.value = []
if (requestId !== loadStatsRequestId) {
return
let hadFailure = false
const markFailure = (error: unknown) => {
hadFailure = true
if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error)
}
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
total_cost: statsData.total_cost || 0,
total_actual_cost: statsData.total_actual_cost,
avg_response_time: statsData.avg_response_time || 0,
error_count: typeof statsRaw.error_count === 'number' ? statsRaw.error_count : undefined,
error_rate: typeof statsRaw.error_rate === 'number' ? statsRaw.error_rate : undefined,
cache_stats: statsRaw.cache_stats as UsageStatsState['cache_stats'],
period_start: '',
period_end: '',
try {
const statsData = await usageApi.getUsageStats(dateRange)
if (requestId !== loadStatsRequestId) {
return true
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
total_cost: statsData.total_cost || 0,
total_actual_cost: statsData.total_actual_cost,
avg_response_time: statsData.avg_response_time || 0,
error_count: typeof statsRaw.error_count === 'number' ? statsRaw.error_count : undefined,
error_rate: typeof statsRaw.error_rate === 'number' ? statsRaw.error_rate : undefined,
cache_stats: statsRaw.cache_stats as UsageStatsState['cache_stats'],
period_start: '',
period_end: '',
}
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
}
markFailure(error)
}
const modelData = await usageApi.getUsageByModel(dateRange)
if (requestId !== loadStatsRequestId) {
return
try {
const modelData = await usageApi.getUsageByModel(dateRange)
if (requestId !== loadStatsRequestId) {
return true
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
return {
model: item.model,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: typeof raw.effective_input_tokens === 'number' ? raw.effective_input_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
}
})
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
}
markFailure(error)
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
return {
model: item.model,
try {
const providerData = await usageApi.getUsageByProvider(dateRange)
if (requestId !== loadStatsRequestId) {
return true
}
providerStats.value = providerData.map(item => ({
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_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,
successRate: item.success_rate,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
}
markFailure(error)
}
try {
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
if (requestId !== loadStatsRequestId) {
return true
}
apiFormatStats.value = apiFormatData.map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: typeof raw.effective_input_tokens === 'number' ? raw.effective_input_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,
effective_input_tokens: item.effective_input_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: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
actual_cost: item.actual_cost,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
}
})
const providerData = await usageApi.getUsageByProvider(dateRange)
if (requestId !== loadStatsRequestId) {
return
markFailure(error)
}
providerStats.value = providerData.map(item => ({
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_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,
successRate: item.success_rate,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
apiFormatStats.value = apiFormatData.map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: item.effective_input_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,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
// 从统计数据中提取可用的筛选选项
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
} else {
// 用户页面
const userData = await meApi.getUsage(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
stats.value = {
total_requests: userData.total_requests || 0,
total_tokens: userData.total_tokens || 0,
total_cost: userData.total_cost || 0,
total_actual_cost: userData.total_actual_cost,
avg_response_time: userData.avg_response_time || 0,
period_start: '',
period_end: '',
}
modelStats.value = (userData.summary_by_model || []).map((item) => ({
model: item.model,
request_count: item.requests || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: item.effective_input_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
}))
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_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,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
// 用户页面:记录直接从 userData 获取(数量较少)
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
// 从记录中提取筛选选项
const models = new Set<string>()
const providers = new Set<string>()
currentRecords.value.forEach(record => {
if (record.model) models.add(record.model)
if (record.provider) providers.add(record.provider)
})
availableModels.value = Array.from(models).sort()
availableProviders.value = Array.from(providers).sort()
// API 格式统计直接使用后端聚合数据
apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: item.effective_input_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
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
return hadFailure
}
// 用户页面
const userData = await meApi.getUsage(dateRange)
if (requestId !== loadStatsRequestId) {
return false
}
stats.value = {
total_requests: userData.total_requests || 0,
total_tokens: userData.total_tokens || 0,
total_cost: userData.total_cost || 0,
total_actual_cost: userData.total_actual_cost,
avg_response_time: userData.avg_response_time || 0,
period_start: '',
period_end: '',
}
modelStats.value = (userData.summary_by_model || []).map((item) => ({
model: item.model,
request_count: item.requests || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: item.effective_input_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
}))
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_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,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
// 用户页面:记录直接从 userData 获取(数量较少)
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
// 从记录中提取筛选选项
const models = new Set<string>()
const providers = new Set<string>()
currentRecords.value.forEach(record => {
if (record.model) models.add(record.model)
if (record.provider) providers.add(record.provider)
})
availableModels.value = Array.from(models).sort()
availableProviders.value = Array.from(providers).sort()
// API 格式统计直接使用后端聚合数据
apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
effective_input_tokens: item.effective_input_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
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
return false
} catch (error: unknown) {
if (requestId !== loadStatsRequestId) {
return
return true
}
if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error)
@@ -261,9 +306,11 @@ export function useUsageData(options: UseUsageDataOptions) {
stats.value = createDefaultStats()
modelStats.value = []
if (!isAdminPage.value) {
// 用户页的 records 依赖 stats 一起加载;管理员页的 records 是独立分页,不应被统计失败清空。
currentRecords.value = []
totalRecords.value = 0
}
return true
} finally {
if (requestId === loadStatsRequestId) {
isLoadingStats.value = false

View File

@@ -254,6 +254,7 @@ const intervalTimelineTitle = computed(() => {
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
let lastAdminAnalyticsRefreshAt = 0
let adminAnalyticsRefreshGeneration = 0
// 加载热力图数据
async function loadHeatmapData() {
@@ -290,30 +291,43 @@ async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
if (!options.force && now - lastAdminAnalyticsRefreshAt < ADMIN_ANALYTICS_REFRESH_INTERVAL) {
return
}
if (adminAnalyticsRefreshInFlight) {
if (!options.force && adminAnalyticsRefreshInFlight) {
return adminAnalyticsRefreshInFlight
}
adminAnalyticsRefreshInFlight = (async () => {
const refreshGeneration = ++adminAnalyticsRefreshGeneration
const refreshPromise = (async () => {
let hasSuccessfulRefresh = false
try {
await loadStats(timeRange.value)
hasSuccessfulRefresh = true
const hadFailure = await loadStats(timeRange.value)
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
return
}
hasSuccessfulRefresh = !hadFailure
if (hadFailure) {
warning('统计数据加载失败,请刷新重试')
}
} catch (error) {
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
return
}
log.error('加载统计数据失败:', error)
warning('统计数据加载失败,请刷新重试')
}
if (hasSuccessfulRefresh) {
if (hasSuccessfulRefresh && refreshGeneration === adminAnalyticsRefreshGeneration) {
lastAdminAnalyticsRefreshAt = Date.now()
}
})()
adminAnalyticsRefreshInFlight = refreshPromise
try {
await adminAnalyticsRefreshInFlight
await refreshPromise
} finally {
adminAnalyticsRefreshInFlight = null
if (adminAnalyticsRefreshInFlight === refreshPromise) {
adminAnalyticsRefreshInFlight = null
}
}
}