diff --git a/src/components/usage/ModelStatsCard.tsx b/src/components/usage/ModelStatsCard.tsx index cfa34dc..75821c6 100644 --- a/src/components/usage/ModelStatsCard.tsx +++ b/src/components/usage/ModelStatsCard.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { + LATENCY_SOURCE_FIELD, formatCompactNumber, formatDurationMs, formatUsd, @@ -35,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar const { t } = useTranslation(); const [sortKey, setSortKey] = useState('requests'); const [sortDir, setSortDir] = useState('desc'); + const latencyHint = t('usage_stats.latency_unit_hint', { + field: LATENCY_SOURCE_FIELD, + unit: t('usage_stats.duration_unit_ms'), + }); const handleSort = (key: SortKey) => { if (sortKey === key) { @@ -66,129 +71,146 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar return list; }, [modelStats, sortKey, sortDir]); - const arrow = (key: SortKey) => - sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; + const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''); const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' => sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'; + const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0); return ( {loading ? (
{t('common.loading')}
) : sorted.length > 0 ? ( -
-
- - - - - - - - - - {hasPrices && ( - + + {sorted.map((stat) => ( + + + + + + + + {hasPrices && } + + ))} + +
- - - - - - - - - - - - + <> + {hasLatencyData &&
{latencyHint}
} +
+
+ + + + - )} - - - - {sorted.map((stat) => ( - - - - - - - } + {t('usage_stats.requests_count')} + {arrow('requests')} + + + + + + + {hasPrices && ( + + )} - ))} - -
{stat.model} - - {stat.requests.toLocaleString()} - - ({stat.successCount.toLocaleString()}{' '} - {stat.failureCount.toLocaleString()}) - - - {formatCompactNumber(stat.tokens)} - {formatDurationMs(stat.averageLatencyMs)} - {formatDurationMs(stat.totalLatencyMs)} - = 95 - ? styles.statSuccess - : stat.successRate >= 80 - ? styles.statNeutral - : styles.statFailure - } + + {stat.cost > 0 ? formatUsd(stat.cost) : '--'} + + + + + + + + + +
+
{stat.model} + + {stat.requests.toLocaleString()} + + ( + + {stat.successCount.toLocaleString()} + {' '} + + {stat.failureCount.toLocaleString()} + + ) + + + {formatCompactNumber(stat.tokens)} + {formatDurationMs(stat.averageLatencyMs)} + + {formatDurationMs(stat.totalLatencyMs)} + + = 95 + ? styles.statSuccess + : stat.successRate >= 80 + ? styles.statNeutral + : styles.statFailure + } + > + {stat.successRate.toFixed(1)}% + + {stat.cost > 0 ? formatUsd(stat.cost) : '--'}
+
- + ) : (
{t('usage_stats.no_data')}
)} diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index df10eea..c5d13dc 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -10,10 +10,11 @@ import type { AuthFileItem } from '@/types/authFile'; import type { CredentialInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; import { - extractLatencyMs, collectUsageDetails, + extractLatencyMs, extractTotalTokens, formatDurationMs, + LATENCY_SOURCE_FIELD, normalizeAuthIndex, } from '@/utils/usage'; import { downloadBlob } from '@/utils/download'; @@ -74,6 +75,10 @@ export function RequestEventsDetailsCard({ openaiProviders, }: RequestEventsDetailsCardProps) { const { t, i18n } = useTranslation(); + const latencyHint = t('usage_stats.latency_unit_hint', { + field: LATENCY_SOURCE_FIELD, + unit: t('usage_stats.duration_unit_ms'), + }); const [modelFilter, setModelFilter] = useState(ALL_FILTER); const [sourceFilter, setSourceFilter] = useState(ALL_FILTER); @@ -422,6 +427,7 @@ export function RequestEventsDetailsCard({ <>
{t('usage_stats.request_events_count', { count: filteredRows.length })} + {hasLatencyData && {latencyHint}} {filteredRows.length > MAX_RENDERED_EVENTS && ( {t('usage_stats.request_events_limit_hint', { @@ -441,7 +447,7 @@ export function RequestEventsDetailsCard({ {t('usage_stats.request_events_source')} {t('usage_stats.request_events_auth_index')} {t('usage_stats.request_events_result')} - {hasLatencyData && {t('usage_stats.time')}} + {hasLatencyData && {t('usage_stats.time')}} {t('usage_stats.input_tokens')} {t('usage_stats.output_tokens')} {t('usage_stats.reasoning_tokens')} diff --git a/src/components/usage/StatCards.tsx b/src/components/usage/StatCards.tsx index 145fe96..d2ef646 100644 --- a/src/components/usage/StatCards.tsx +++ b/src/components/usage/StatCards.tsx @@ -9,12 +9,13 @@ import { IconTrendingUp, } from '@/components/ui/icons'; import { + LATENCY_SOURCE_FIELD, calculateLatencyStatsFromDetails, + calculateCost, formatCompactNumber, formatDurationMs, formatPerMinuteValue, formatUsd, - calculateCost, collectUsageDetails, extractTotalTokens, type ModelPrice, @@ -52,6 +53,10 @@ export interface StatCardsProps { export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) { const { t } = useTranslation(); + const latencyHint = t('usage_stats.latency_unit_hint', { + field: LATENCY_SOURCE_FIELD, + unit: t('usage_stats.duration_unit_ms'), + }); const hasPrices = Object.keys(modelPrices).length > 0; @@ -60,7 +65,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 }, rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 }, totalCost: 0, - latencyStats: { averageMs: null as number | null, totalMs: null as number | null, sampleCount: 0 }, + latencyStats: { + averageMs: null as number | null, + totalMs: null as number | null, + sampleCount: 0, + }, }; if (!usage) return empty; @@ -141,7 +150,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} {latencyStats.sampleCount > 0 && ( - + {t('usage_stats.avg_time')}:{' '} {loading ? '-' : formatDurationMs(latencyStats.averageMs)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index af4ae5e..0b9dfd1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1029,9 +1029,15 @@ "request_events_source": "Source", "request_events_auth_index": "Auth Index", "request_events_result": "Result", - "time": "Time", - "avg_time": "Avg Time", - "total_time": "Total Time", + "time": "Latency", + "avg_time": "Avg Latency", + "total_time": "Total Latency", + "latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.", + "duration_unit_d": "d", + "duration_unit_h": "h", + "duration_unit_m": "m", + "duration_unit_s": "s", + "duration_unit_ms": "ms", "request_events_empty_title": "No request events", "request_events_empty_desc": "No request details are available for the selected time range.", "request_events_no_result_title": "No matching events", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5ba7a10..cf18347 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1026,9 +1026,15 @@ "request_events_source": "Источник", "request_events_auth_index": "Auth Index", "request_events_result": "Результат", - "time": "Время", - "avg_time": "Среднее время", - "total_time": "Общее время", + "time": "Задержка", + "avg_time": "Средняя задержка", + "total_time": "Суммарная задержка", + "latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.", + "duration_unit_d": "д", + "duration_unit_h": "ч", + "duration_unit_m": "мин", + "duration_unit_s": "с", + "duration_unit_ms": "мс", "request_events_empty_title": "События запросов отсутствуют", "request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.", "request_events_no_result_title": "Совпадений не найдено", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cf30943..706c030 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1029,9 +1029,15 @@ "request_events_source": "来源", "request_events_auth_index": "认证索引", "request_events_result": "结果", - "time": "耗时", - "avg_time": "平均耗时", - "total_time": "总耗时", + "time": "延迟", + "avg_time": "平均延迟", + "total_time": "总延迟", + "latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。", + "duration_unit_d": "天", + "duration_unit_h": "时", + "duration_unit_m": "分", + "duration_unit_s": "秒", + "duration_unit_ms": "毫秒", "request_events_empty_title": "暂无请求事件", "request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。", "request_events_no_result_title": "没有匹配结果", diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index fb2941f..bf533c4 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -128,8 +128,7 @@ padding: 18px; background: radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%), - linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), - var(--bg-primary); + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary); border-radius: $radius-lg; border: 1px solid var(--border-color); display: flex; @@ -137,7 +136,10 @@ gap: 10px; min-height: 176px; box-shadow: var(--shadow-lg); - transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; + transition: + transform $transition-fast, + box-shadow $transition-fast, + border-color $transition-fast; overflow: hidden; &::before { @@ -350,7 +352,10 @@ font-size: 11px; font-weight: 500; cursor: pointer; - transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; &:hover { background: var(--bg-tertiary); @@ -505,6 +510,12 @@ overscroll-behavior: contain; } +.detailsNote { + padding: 0 4px 10px; + font-size: 12px; + color: var(--text-secondary); +} + // Table (80%比例) .tableWrapper { overflow-x: auto; @@ -515,7 +526,8 @@ border-collapse: collapse; font-size: 12px; - th, td { + th, + td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border-color); @@ -1100,7 +1112,9 @@ width: 100%; height: 100%; border-radius: 2px; - transition: transform 0.15s ease, opacity 0.15s ease; + transition: + transform 0.15s ease, + opacity 0.15s ease; .healthBlockWrapper:hover &, .healthBlockWrapper.healthBlockActive & { diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 1d02e5a..70d2c3a 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -4,8 +4,25 @@ */ import type { ScriptableContext } from 'chart.js'; +import type { LatencyAccumulator, LatencyStats } from './usage/latency'; +import { + addLatencySample, + calculateLatencyStatsFromDetails, + createLatencyAccumulator, + extractLatencyMs, + finalizeLatencyStats, +} from './usage/latency'; import { maskApiKey } from './format'; +export type { DurationFormatOptions, LatencyStats } from './usage/latency'; +export { + LATENCY_SOURCE_FIELD, + LATENCY_SOURCE_UNIT, + calculateLatencyStatsFromDetails, + extractLatencyMs, + formatDurationMs, +} from './usage/latency'; + export interface KeyStatBucket { success: number; failure: number; @@ -29,18 +46,6 @@ export interface RateStats { tokenCount: number; } -export interface LatencyStats { - averageMs: number | null; - totalMs: number | null; - sampleCount: number; -} - -export interface DurationFormatOptions { - maxUnits?: number; - invalidText?: string; - secondDecimals?: number | 'auto'; -} - export interface ModelPrice { prompt: number; completion: number; @@ -656,150 +661,6 @@ export function extractTotalTokens(detail: unknown): number { return inputTokens + outputTokens + reasoningTokens + cachedTokens; } -/** - * 从单条明细提取耗时(毫秒) - */ -export function extractLatencyMs(detail: unknown): number | null { - const record = isRecord(detail) ? detail : null; - const rawValue = record?.latency_ms; - if ( - rawValue === null || - rawValue === undefined || - (typeof rawValue === 'string' && rawValue.trim() === '') - ) { - return null; - } - const parsed = Number(rawValue); - if (!Number.isFinite(parsed) || parsed < 0) { - return null; - } - return parsed; -} - -interface LatencyAccumulator { - totalMs: number; - sampleCount: number; -} - -const createLatencyAccumulator = (): LatencyAccumulator => ({ - totalMs: 0, - sampleCount: 0, -}); - -const addLatencySample = ( - accumulator: LatencyAccumulator, - latencyMs: number | null | undefined -): void => { - if (latencyMs === null || latencyMs === undefined) { - return; - } - const parsed = Number(latencyMs); - if (!Number.isFinite(parsed) || parsed < 0) { - return; - } - accumulator.totalMs += parsed; - accumulator.sampleCount += 1; -}; - -const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({ - averageMs: - accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null, - totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null, - sampleCount: accumulator.sampleCount, -}); - -const trimTrailingZeros = (value: string): string => value.replace(/\.?0+$/, ''); - -const normalizeDurationMaxUnits = (value: number | undefined): number => { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 2; - } - return Math.min(Math.floor(parsed), 4); -}; - -const resolveSecondDecimalPlaces = ( - seconds: number, - secondDecimals: number | 'auto' | undefined -): number => { - if (secondDecimals === 'auto' || secondDecimals === undefined) { - return seconds < 10 ? 2 : 1; - } - - const parsed = Math.floor(Number(secondDecimals)); - if (!Number.isFinite(parsed) || parsed < 0) { - return seconds < 10 ? 2 : 1; - } - return Math.min(parsed, 3); -}; - -/** - * 格式化耗时显示 - */ -export function formatDurationMs( - value: number | null | undefined, - options: DurationFormatOptions = {} -): string { - const invalidText = options.invalidText ?? '--'; - if (value === null || value === undefined) { - return invalidText; - } - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0) { - return invalidText; - } - - if (parsed < 1000) { - return `${Math.round(parsed)}ms`; - } - - const seconds = parsed / 1000; - if (seconds < 60) { - const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals); - return `${trimTrailingZeros(seconds.toFixed(secondDecimalPlaces))}s`; - } - - const totalSeconds = Math.floor(seconds); - let remainingSeconds = totalSeconds; - const days = Math.floor(remainingSeconds / 86_400); - remainingSeconds -= days * 86_400; - const hours = Math.floor(remainingSeconds / 3_600); - remainingSeconds -= hours * 3_600; - const minutes = Math.floor(remainingSeconds / 60); - remainingSeconds -= minutes * 60; - - const parts = [ - { label: 'd', value: days }, - { label: 'h', value: hours }, - { label: 'm', value: minutes }, - { label: 's', value: remainingSeconds }, - ].filter((part) => part.value > 0); - - if (!parts.length) { - return '0s'; - } - - return parts - .slice(0, normalizeDurationMaxUnits(options.maxUnits)) - .map((part, index) => { - const shouldPad = index > 0 && (part.label === 'm' || part.label === 's'); - const unitValue = shouldPad ? String(part.value).padStart(2, '0') : String(part.value); - return `${unitValue}${part.label}`; - }) - .join(' '); -} - -/** - * 从明细列表计算耗时统计 - */ -export function calculateLatencyStatsFromDetails(details: Iterable): LatencyStats { - const accumulator = createLatencyAccumulator(); - for (const detail of details) { - addLatencySample(accumulator, extractLatencyMs(detail)); - } - return finalizeLatencyStats(accumulator); -} - /** * 计算耗时统计 */ diff --git a/src/utils/usage/latency.ts b/src/utils/usage/latency.ts new file mode 100644 index 0000000..b109e6b --- /dev/null +++ b/src/utils/usage/latency.ts @@ -0,0 +1,199 @@ +import i18n from '@/i18n'; + +export const LATENCY_SOURCE_FIELD = 'latency_ms'; +export const LATENCY_SOURCE_UNIT = 'ms'; + +export interface LatencyStats { + averageMs: number | null; + totalMs: number | null; + sampleCount: number; +} + +export interface DurationFormatOptions { + maxUnits?: number; + invalidText?: string; + secondDecimals?: number | 'auto'; + locale?: string; +} + +export interface LatencyAccumulator { + totalMs: number; + sampleCount: number; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeDurationMaxUnits = (value: number | undefined): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2; + } + return Math.min(Math.floor(parsed), 4); +}; + +const resolveSecondDecimalPlaces = ( + seconds: number, + secondDecimals: number | 'auto' | undefined +): number => { + if (secondDecimals === 'auto' || secondDecimals === undefined) { + return seconds < 10 ? 2 : 1; + } + + const parsed = Math.floor(Number(secondDecimals)); + if (!Number.isFinite(parsed) || parsed < 0) { + return seconds < 10 ? 2 : 1; + } + return Math.min(parsed, 3); +}; + +const resolveDurationLocale = (locale?: string): string | undefined => + locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined; + +const formatDurationNumber = ( + value: number, + locale: string | undefined, + options: Intl.NumberFormatOptions = {} +): string => { + try { + return new Intl.NumberFormat(locale, { + useGrouping: false, + ...options, + }).format(value); + } catch { + return String(value); + } +}; + +const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string => + i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit }); + +const formatDurationPart = ( + value: number, + unit: 'd' | 'h' | 'm' | 's' | 'ms', + locale: string | undefined, + options: Intl.NumberFormatOptions = {} +): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`; + +/** + * 从后端字段 latency_ms 提取耗时,并按毫秒解释。 + */ +export function extractLatencyMs(detail: unknown): number | null { + const record = isRecord(detail) ? detail : null; + const rawValue = record?.[LATENCY_SOURCE_FIELD]; + if ( + rawValue === null || + rawValue === undefined || + (typeof rawValue === 'string' && rawValue.trim() === '') + ) { + return null; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +export const createLatencyAccumulator = (): LatencyAccumulator => ({ + totalMs: 0, + sampleCount: 0, +}); + +export const addLatencySample = ( + accumulator: LatencyAccumulator, + latencyMs: number | null | undefined +): void => { + if (latencyMs === null || latencyMs === undefined) { + return; + } + + const parsed = Number(latencyMs); + if (!Number.isFinite(parsed) || parsed < 0) { + return; + } + + accumulator.totalMs += parsed; + accumulator.sampleCount += 1; +}; + +export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({ + averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null, + totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null, + sampleCount: accumulator.sampleCount, +}); + +/** + * 从明细列表计算耗时统计 + */ +export function calculateLatencyStatsFromDetails(details: Iterable): LatencyStats { + const accumulator = createLatencyAccumulator(); + for (const detail of details) { + addLatencySample(accumulator, extractLatencyMs(detail)); + } + return finalizeLatencyStats(accumulator); +} + +/** + * 按当前语言格式化耗时显示。 + */ +export function formatDurationMs( + value: number | null | undefined, + options: DurationFormatOptions = {} +): string { + const invalidText = options.invalidText ?? '--'; + if (value === null || value === undefined) { + return invalidText; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return invalidText; + } + + const locale = resolveDurationLocale(options.locale); + + if (parsed < 1000) { + return formatDurationPart(Math.round(parsed), 'ms', locale); + } + + const seconds = parsed / 1000; + if (seconds < 60) { + const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals); + return formatDurationPart(seconds, 's', locale, { + minimumFractionDigits: 0, + maximumFractionDigits: secondDecimalPlaces, + }); + } + + const totalSeconds = Math.floor(seconds); + let remainingSeconds = totalSeconds; + const days = Math.floor(remainingSeconds / 86_400); + remainingSeconds -= days * 86_400; + const hours = Math.floor(remainingSeconds / 3_600); + remainingSeconds -= hours * 3_600; + const minutes = Math.floor(remainingSeconds / 60); + remainingSeconds -= minutes * 60; + + const parts = [ + { unit: 'd' as const, value: days }, + { unit: 'h' as const, value: hours }, + { unit: 'm' as const, value: minutes }, + { unit: 's' as const, value: remainingSeconds }, + ].filter((part) => part.value > 0); + + if (!parts.length) { + return formatDurationPart(0, 's', locale); + } + + return parts + .slice(0, normalizeDurationMaxUnits(options.maxUnits)) + .map((part, index) => + formatDurationPart(part.value, part.unit, locale, { + minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1, + maximumFractionDigits: 0, + }) + ) + .join(' '); +}