From e06778e38cb3ea419dc7a1906bbfe162b35115fe Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 08:30:23 +0800 Subject: [PATCH] perf(web): reuse locale formatters --- .../apps/web/src/v2/domain/access.ts | 8 +++++--- .../apps/web/src/v2/domain/alert.ts | 9 ++++++--- .../apps/web/src/v2/domain/formatters.test.ts | 15 +++++++++++++++ .../apps/web/src/v2/domain/formatters.ts | 10 ++++++++++ .../apps/web/src/v2/domain/history.ts | 3 ++- .../apps/web/src/v2/domain/monitor.ts | 3 ++- .../apps/web/src/v2/domain/telemetry.ts | 4 +++- .../apps/web/src/v2/pages/AccessPage.tsx | 3 ++- .../apps/web/src/v2/pages/HistoryPage.tsx | 4 +++- .../apps/web/src/v2/pages/StatisticsPage.tsx | 3 ++- .../apps/web/src/v2/pages/VehiclePage.tsx | 5 +++-- 11 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/formatters.ts diff --git a/vehicle-data-platform/apps/web/src/v2/domain/access.ts b/vehicle-data-platform/apps/web/src/v2/domain/access.ts index cc28c9ff..61ef5b55 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/access.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/access.ts @@ -1,5 +1,9 @@ import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types'; +const accessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false +}); + export const accessStateLabels: Record = { online: '在线', offline: '离线', @@ -22,9 +26,7 @@ export function formatAccessTime(value: string) { if (!value) return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return '—'; - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false - }).format(parsed).replace(/\//g, '-'); + return accessTimeFormatter.format(parsed).replace(/\//g, '-'); } export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) { diff --git a/vehicle-data-platform/apps/web/src/v2/domain/alert.ts b/vehicle-data-platform/apps/web/src/v2/domain/alert.ts index 7c79ec18..8fa8d13d 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/alert.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/alert.ts @@ -1,4 +1,7 @@ import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types'; +import { formatZhNumber } from './formatters'; + +const alertTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); export const severityLabels: Record = { critical: '紧急', major: '重要', minor: '一般' }; export const statusLabels: Record = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' }; @@ -10,18 +13,18 @@ export function formatAlertTime(value: string) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19); - return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date).replace(/\//g, '-'); + return alertTimeFormatter.format(date).replace(/\//g, '-'); } export function alertValue(event: Pick) { - return `${Number(event.triggerValue.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}`.trim(); + return `${formatZhNumber(Number(event.triggerValue.toFixed(2)), 2)} ${event.unit}`.trim(); } export function thresholdText(event: Pick) { const duration = event.durationSec > 0 ? `,持续 ${event.durationSec} 秒` : ''; if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}–${event.thresholdHigh} ${event.unit}${duration}`.trim(); if (event.operator === 'changed') return `状态发生变化${duration}`; - return `${operatorLabels[event.operator] ?? event.operator} ${Number(event.threshold.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}${duration}`.trim(); + return `${operatorLabels[event.operator] ?? event.operator} ${formatZhNumber(Number(event.threshold.toFixed(2)), 2)} ${event.unit}${duration}`.trim(); } export function ruleCondition(rule: AlertRule, labels: Record = metricLabels) { diff --git a/vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts new file mode 100644 index 00000000..c43f7a67 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { formatZhNumber } from './formatters'; + +describe('formatZhNumber', () => { + it('formats Chinese locale numbers with independent precision caches', () => { + expect(formatZhNumber(1234.56)).toBe('1,235'); + expect(formatZhNumber(1234.56, 1)).toBe('1,234.6'); + expect(formatZhNumber(1234.56, 2)).toBe('1,234.56'); + expect(formatZhNumber(1234.56, 1)).toBe('1,234.6'); + }); + + it('preserves Intl handling for non-finite numeric values', () => { + expect(formatZhNumber(Number.NaN, 2)).toBe('NaN'); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts b/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts new file mode 100644 index 00000000..f6121e0d --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts @@ -0,0 +1,10 @@ +const zhNumberFormatters = new Map(); + +export function formatZhNumber(value: number, maximumFractionDigits = 0) { + let formatter = zhNumberFormatters.get(maximumFractionDigits); + if (!formatter) { + formatter = new Intl.NumberFormat('zh-CN', { maximumFractionDigits }); + zhNumberFormatters.set(maximumFractionDigits, formatter); + } + return formatter.format(value); +} diff --git a/vehicle-data-platform/apps/web/src/v2/domain/history.ts b/vehicle-data-platform/apps/web/src/v2/domain/history.ts index aa5d9182..d81f410d 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/history.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/history.ts @@ -1,4 +1,5 @@ import type { HistoryDataRow, HistoryExportJob, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types'; +import { formatZhNumber } from './formatters'; export const HISTORY_EXPORT_POLL_MS = { running: 3_000, @@ -23,7 +24,7 @@ export function parseHistoryKeywords(value: string) { export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) { if (value == null || value === '') return '—'; - const formatted = typeof value === 'number' ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value) : String(value); + const formatted = typeof value === 'number' ? formatZhNumber(value, 6) : String(value); return metric?.unit ? `${formatted} ${metric.unit}` : formatted; } diff --git a/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts b/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts index ef45936a..f82743f6 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts @@ -1,4 +1,5 @@ import type { VehicleRealtimeRow } from '../../api/types'; +import { formatZhNumber } from './formatters'; export type FleetStatus = 'online' | 'offline' | 'driving' | 'idle' | 'alert' | 'unknown'; @@ -31,5 +32,5 @@ export function relativeFreshness(value: string) { } export function formatNumber(value: number, maximumFractionDigits = 0) { - return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value); + return formatZhNumber(value, maximumFractionDigits); } diff --git a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts index 7243bdfc..60d90c64 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts @@ -1,6 +1,8 @@ +import { formatZhNumber } from './formatters'; + export function formatTelemetryValue(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { - return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value); + return formatZhNumber(value, 2); } if (typeof value === 'boolean') return value ? '是' : '否'; if (typeof value === 'string') return value || '—'; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index d4687ee4..ec05786c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -12,6 +12,7 @@ import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../q import { downloadBlob } from '../domain/download'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; +const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; type Filters = typeof EMPTY_FILTERS; @@ -27,7 +28,7 @@ function compactTime(value: string) { if (!value) return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return '—'; - return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace(/\//g, '-'); + return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-'); } function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index ac3e2a8f..1dbfd8d1 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -10,6 +10,8 @@ import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { usePlatformSession } from '../auth/AuthGate'; import { canOperate } from '../auth/session'; +const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); + function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) { const panels = useMemo(() => buildHistorySeriesPanels(response), [response]); if (category !== 'location') return
聚合趋势
{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}
; @@ -28,7 +30,7 @@ function HistoryTrend({ response, category, loading, error }: { response?: Histo export function formatAxisTime(value: string) { const parsed = new Date(value); if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16); - return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace('/', '-'); + return historyAxisTimeFormatter.format(parsed).replace('/', '-'); } function currentHistoryWindow() { const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index 012be140..d33e46d3 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types'; import { downloadMileageWorkbook } from '../domain/mileageExport'; +import { formatZhNumber } from '../domain/formatters'; import { InlineError } from '../shared/AsyncState'; import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; @@ -41,7 +42,7 @@ function defaultWindow(days = 30) { function formatKm(value?: number) { if (value == null || !Number.isFinite(value)) return '—'; - return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value); + return formatZhNumber(value, 1); } function inclusiveDays(dateFrom: string, dateTo: string) { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index b5bc8c01..5ecaca12 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -11,15 +11,16 @@ import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { QUERY_MEMORY } from '../queryPolicy'; import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry'; +import { formatZhNumber } from '../domain/formatters'; import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync'; import { FleetMap } from '../map/FleetMap'; import { InlineError, PageLoading } from '../shared/AsyncState'; function fmt(value?: string) { return value?.trim() || '—'; } -function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value) : fallback; } +function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; } function timeOnly(value?: string) { if (!value) return '—'; const parts = value.split(' '); return parts[parts.length - 1] || value; } function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; } -function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(seconds / 3600)} 小时`; } +function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; } function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; } const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;