perf(web): reuse locale formatters
This commit is contained in:
@@ -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<AccessVehicleRow['onlineState'], string> = {
|
||||
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) {
|
||||
|
||||
@@ -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<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
||||
export const statusLabels: Record<AlertStatus, string> = { 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<AlertEvent, 'triggerValue' | 'unit'>) {
|
||||
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<AlertEvent, 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
|
||||
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<string, string> = metricLabels) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
10
vehicle-data-platform/apps/web/src/v2/domain/formatters.ts
Normal file
10
vehicle-data-platform/apps/web/src/v2/domain/formatters.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const zhNumberFormatters = new Map<number, Intl.NumberFormat>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 || '—';
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 <section className="v2-history-trend"><header><strong>聚合趋势</strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user