diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index c5d13dc..2a69b22 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@ import type { AuthFileItem } from '@/types/authFile'; import type { CredentialInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { parseTimestampMs } from '@/utils/timestamp'; import { collectUsageDetails, extractLatencyMs, @@ -131,7 +132,7 @@ export function RequestEventsDetailsCard({ const timestampMs = typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0 ? detail.__timestampMs - : Date.parse(timestamp); + : parseTimestampMs(timestamp); const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs); const sourceRaw = String(detail.source ?? '').trim(); const authIndexRaw = detail.auth_index as unknown; diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 93e60c2..c8c1a4a 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconQwen from '@/assets/icons/qwen.svg'; import iconVertex from '@/assets/icons/vertex.svg'; import type { AuthFileItem } from '@/types'; +import { parseTimestamp } from '@/utils/timestamp'; import { normalizeAuthIndex, normalizeUsageSourceId, @@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => { const date = Number.isFinite(asNumber) && !Number.isNaN(asNumber) ? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber) - : new Date(String(raw)); + : parseTimestamp(raw) ?? new Date(String(raw)); return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(); }; diff --git a/src/pages/hooks/useTraceResolver.ts b/src/pages/hooks/useTraceResolver.ts index daab3f9..41da24e 100644 --- a/src/pages/hooks/useTraceResolver.ts +++ b/src/pages/hooks/useTraceResolver.ts @@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; import type { AuthFileItem, Config } from '@/types'; import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { parseTimestampMs } from '@/utils/timestamp'; import { collectUsageDetailsWithEndpoint, normalizeAuthIndex, @@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso if (!logPath) return []; const logTimestampMs = traceLogLine.timestamp - ? Date.parse(traceLogLine.timestamp) + ? parseTimestampMs(traceLogLine.timestamp) : Number.NaN; // Step 1: filter by path match diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 5cce3d0..8494188 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -5,6 +5,7 @@ import { apiClient } from './client'; import type { AuthFilesResponse } from '@/types/authFile'; import type { OAuthModelAliasEntry } from '@/types'; +import { parseTimestampMs } from '@/utils/timestamp'; type StatusError = { status?: number }; type AuthFileStatusResponse = { status: string; disabled: boolean }; @@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => { if (Number.isFinite(asNumber)) { return asNumber < 1e12 ? asNumber * 1000 : asNumber; } - const parsed = Date.parse(trimmed); + const parsed = parseTimestampMs(trimmed); if (!Number.isNaN(parsed)) { return parsed; } diff --git a/src/utils/format.ts b/src/utils/format.ts index f9eadf5..ba80460 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,5 @@ +import { parseTimestamp } from './timestamp'; + /** * 格式化工具函数 * 从原项目 src/utils/string.js 迁移 @@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string { * 格式化日期时间 */ export function formatDateTime(date: string | Date, locale?: string): string { - const d = typeof date === 'string' ? new Date(date) : date; + const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date; if (isNaN(d.getTime())) { return 'Invalid Date'; @@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string { const asNumber = typeof value === 'number' ? value : Number(value); const date = (() => { if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) { - return new Date(String(value)); + return parseTimestamp(value) ?? new Date(String(value)); } const abs = Math.abs(asNumber); diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts new file mode 100644 index 0000000..19eff5a --- /dev/null +++ b/src/utils/timestamp.ts @@ -0,0 +1,58 @@ +const RFC3339_HIGH_PRECISION_REGEX = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i; + +/** + * Some browsers mis-handle RFC3339 timestamps that include sub-millisecond + * precision. Normalize them to millisecond precision before parsing. + */ +export function normalizeTimestampForDateParse(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + + const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX); + if (!match) return trimmed; + + const [, base, , fractionDigits = '', timezone = ''] = match; + if (fractionDigits.length <= 3) { + return trimmed; + } + + return `${base}.${fractionDigits.slice(0, 3)}${timezone}`; +} + +export function parseTimestampMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value !== 'string') { + return Number.NaN; + } + + const trimmed = value.trim(); + if (!trimmed) { + return Number.NaN; + } + + const normalized = normalizeTimestampForDateParse(trimmed); + const normalizedParsed = Date.parse(normalized); + if (!Number.isNaN(normalizedParsed)) { + return normalizedParsed; + } + + if (normalized !== trimmed) { + const originalParsed = Date.parse(trimmed); + if (!Number.isNaN(originalParsed)) { + return originalParsed; + } + } + + return Number.NaN; +} + +export function parseTimestamp(value: unknown): Date | null { + const timestampMs = parseTimestampMs(value); + if (!Number.isFinite(timestampMs)) { + return null; + } + return new Date(timestampMs); +} diff --git a/src/utils/usage.ts b/src/utils/usage.ts index a6827b5..8d53755 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -13,6 +13,7 @@ import { finalizeLatencyStats, } from './usage/latency'; import { maskApiKey } from './format'; +import { parseTimestampMs } from './timestamp'; export type { DurationFormatOptions, LatencyStats } from './usage/latency'; export { @@ -195,7 +196,7 @@ export function filterUsageByTimeRange( if (!detailRecord || typeof detailRecord.timestamp !== 'string') { return; } - const timestamp = Date.parse(detailRecord.timestamp); + const timestamp = parseTimestampMs(detailRecord.timestamp); if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) { return; } @@ -545,7 +546,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ @@ -618,7 +619,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ @@ -721,7 +722,7 @@ export function calculateRecentPerMinuteRates( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) { return; } @@ -1131,7 +1132,7 @@ export function buildHourlySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1190,7 +1191,7 @@ export function buildDailySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1416,7 +1417,7 @@ export function calculateStatusBarData( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1524,7 +1525,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1734,7 +1735,7 @@ export function buildHourlyTokenBreakdown( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1776,7 +1777,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; @@ -1853,7 +1854,7 @@ export function buildHourlyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1888,7 +1889,7 @@ export function buildDailyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return;