mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
chore: resolve pr 377 checks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user