From 5805635ccf0a3f4c411c833aa9ebf65c0bfea229 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 10:51:55 +0800 Subject: [PATCH] unify Shanghai time across Semi UI workspaces --- .../apps/web/src/v2/domain/formatters.test.ts | 16 ++++++++++- .../apps/web/src/v2/domain/formatters.ts | 27 ++++++++++++++++++ .../apps/web/src/v2/pages/HistoryPage.tsx | 28 ++++++------------- .../web/src/v2/pages/OperationsPage.test.tsx | 5 +++- .../apps/web/src/v2/pages/OperationsPage.tsx | 15 ++++------ .../web/src/v2/pages/ReconciliationCenter.tsx | 17 ++++------- .../web/src/v2/shared/PlatformTime.test.tsx | 19 +++++++++++++ .../apps/web/src/v2/shared/PlatformTime.tsx | 20 +++++++++++++ .../apps/web/src/v2/styles/workspace.css | 12 ++++++++ 9 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/PlatformTime.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/PlatformTime.tsx 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 index c43f7a67..4208d880 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/formatters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatZhNumber } from './formatters'; +import { formatShanghaiDateTime, formatZhNumber } from './formatters'; describe('formatZhNumber', () => { it('formats Chinese locale numbers with independent precision caches', () => { @@ -13,3 +13,17 @@ describe('formatZhNumber', () => { expect(formatZhNumber(Number.NaN, 2)).toBe('NaN'); }); }); + +describe('formatShanghaiDateTime', () => { + it('renders explicit timestamps in Shanghai and preserves local business values', () => { + expect(formatShanghaiDateTime('2026-07-18T02:33:00Z')).toBe('2026-07-18 10:33:00'); + expect(formatShanghaiDateTime('2026-07-18T02:33:00+08:00')).toBe('2026-07-18 02:33:00'); + expect(formatShanghaiDateTime('2026-07-18 02:33:00')).toBe('2026-07-18 02:33:00'); + }); + + it('converts timezone-less UTC service timestamps only when the source contract says UTC', () => { + expect(formatShanghaiDateTime('2026-07-18 02:33:00', { sourceZone: 'utc' })).toBe('2026-07-18 10:33:00'); + expect(formatShanghaiDateTime('', { sourceZone: 'utc' })).toBe('—'); + expect(formatShanghaiDateTime(undefined, { empty: '尚未上报' })).toBe('尚未上报'); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts b/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts index f6121e0d..32689c31 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/formatters.ts @@ -1,4 +1,16 @@ const zhNumberFormatters = new Map(); +const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false +}); + +export type DateTimeSourceZone = 'preserve' | 'utc'; export function formatZhNumber(value: number, maximumFractionDigits = 0) { let formatter = zhNumberFormatters.get(maximumFractionDigits); @@ -8,3 +20,18 @@ export function formatZhNumber(value: number, maximumFractionDigits = 0) { } return formatter.format(value); } + +export function formatShanghaiDateTime( + value?: string, + { empty = '—', sourceZone = 'preserve' }: { empty?: string; sourceZone?: DateTimeSourceZone } = {} +) { + if (!value?.trim()) return empty; + const source = value.trim(); + const fallback = source.replace('T', ' ').replace(/\.\d{1,9}/, '').replace(/Z$/, '').slice(0, 19); + const hasExplicitZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(source); + if (!hasExplicitZone && sourceZone === 'preserve') return fallback; + const normalized = hasExplicitZone ? source : `${source.replace(' ', 'T')}Z`; + const parsed = new Date(normalized); + if (!Number.isFinite(parsed.getTime())) return fallback || empty; + return shanghaiDateTimeFormatter.format(parsed).replace('T', ' '); +} 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 a8dc8656..82b61dfc 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -7,8 +7,10 @@ import { api } from '../../api/client'; import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; import { downloadBlob } from '../domain/download'; +import { formatShanghaiDateTime } from '../domain/formatters'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; +import { PlatformTime } from '../shared/PlatformTime'; import { TablePagination } from '../shared/TablePagination'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; @@ -25,16 +27,6 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const DESKTOP_HISTORY_PAGE_SIZE = 50; const MOBILE_HISTORY_PAGE_SIZE = 20; const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); -const historyDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false -}); function historyQualityLabel(quality: string) { if (quality === 'normal' || quality === 'good') return '正常'; @@ -99,11 +91,7 @@ export function formatAxisTime(value: string) { } export function formatHistoryDateTime(value: string) { - const fallback = value.replace('T', ' ').replace(/\.\d{1,9}/, '').replace(/Z$/, '').slice(0, 19); - if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) return fallback; - const parsed = new Date(value); - if (!Number.isFinite(parsed.getTime())) return fallback; - return historyDateTimeFormatter.format(parsed).replace('T', ' '); + return formatShanghaiDateTime(value); } function currentHistoryWindow() { @@ -152,8 +140,8 @@ function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: His {row ? <>{formatHistoryDateTime(row.deviceTime)} }, - { key: '服务时间', value: }, + { key: '设备时间', value: }, + { key: '服务时间', value: }, { key: '数据来源', value: {row.protocol} }, { key: '数据质量', value: }, { key: '质量原因', value: row.qualityReason || '平台未返回质量说明' } @@ -203,8 +191,8 @@ function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: Hi title: '', dataIndex: 'selection', width: 42, render: (_: unknown, row: HistoryDataRow) => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /> }, - { title: '设备时间', dataIndex: 'deviceTime', width: 150, render: (value: string) => }, - { title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => }, + { title: '设备时间', dataIndex: 'deviceTime', width: 150, render: (value: string) => }, + { title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => }, { title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' }, { title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => {value} }, { title: '协议', dataIndex: 'protocol', width: 100 }, @@ -440,7 +428,7 @@ export default function HistoryPage() { aria-label={mobileLayout ? '历史明细列表,可上下滚动' : undefined} > {mobileLayout - ?
{resultRows.map((row) => )}
+ ?
{resultRows.map((row) => )}
: } {dataQuery.isPending && keywords.length ? diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index e5dda3fd..0e50b814 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -82,6 +82,9 @@ test('renders reconciliation queue, loads evidence on demand and records review expect(screen.getByText('近 30 天趋势').closest('.v2-workspace-panel-header')).toHaveClass('is-compact'); expect(trendButton).toHaveAttribute('aria-expanded', 'true'); expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).toBeInTheDocument(); + expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间'); + expect(screen.getAllByText('2026-07-16 18:00:00').length).toBeGreaterThan(0); + expect(document.querySelector('.v2-platform-time.v2-ops-time')).toBeInTheDocument(); const issueTitles = await screen.findAllByText('多来源实时位置漂移'); expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).not.toBeInTheDocument(); expect(document.querySelector('.v2-reconcile-table-wrap > table')).not.toBeInTheDocument(); @@ -229,7 +232,7 @@ test('keeps health evidence visible when source readiness fails and retries that fireEvent.click(screen.getByRole('tab', { name: '全局健康' })); expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument(); - expect(screen.getByText('版本 test-release')).toBeInTheDocument(); + expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间 · 版本 test-release'); expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /重试/ })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index 18b1b8dc..ba333b68 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -5,6 +5,7 @@ import { FormEvent, useDeferredValue, useEffect, useState } from 'react'; import { api } from '../../api/client'; import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; +import { PlatformTime } from '../shared/PlatformTime'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; @@ -26,12 +27,6 @@ function HealthTag({ status, children }: { status: string; children?: string }) return {children ?? statusLabel(status)}; } -function fmt(value?: string) { - if (!value) return '—'; - const parsed = new Date(value.replace(' ', 'T')); - return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }); -} - function number(value?: number, digits = 1) { return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits }); } @@ -113,7 +108,7 @@ function SourceHealth({ source }: { source: VehicleLocationSourceEvidence }) { } function SourceTiming({ source }: { source: VehicleLocationSourceEvidence }) { - return
{fmt(source.firstSeenAt)}最近 {fmt(source.receivedAt || source.eventTime)}
; + return
最近
; } function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) { @@ -313,7 +308,7 @@ function SourceDiagnosticWorkspace() { 车辆{data.evidence.plate || selected?.plate || '未绑定车牌'}{data.evidence.vin} 当前推荐{data.evidence.recommendedLocationLabel || '暂无推荐'}{data.evidence.recommendedLocationProtocol || '—'} 来源状态{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'} - 策略版本v{data.policy.version}{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'} + 策略版本v{data.policy.version}{data.policy.updatedAt ? <>{data.policy.updatedBy} · : '尚无人工调整'}
推荐说明{data.refreshHint}

{data.recommendationReason}

{!editable ?
只读权限当前账号可查看诊断证据;只有管理员可以调整启停和优先级。
: null} @@ -321,7 +316,7 @@ function SourceDiagnosticWorkspace() { ? : } - {data.policy.audit.length ?
    {data.policy.audit.map((item) =>
  1. v{item.version}{item.summary}{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}
  2. )}
:

该车辆尚无人工来源策略或提供方变更。

} + {data.policy.audit.length ?
    {data.policy.audit.map((item) =>
  1. v{item.version}{item.summary}{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} ·
  2. )}
:

该车辆尚无人工来源策略或提供方变更。

}
: null} @@ -356,7 +351,7 @@ export default function OperationsPage() { description="集中处理自动对账问题,按需诊断单车来源,并核对服务与协议健康;所有结论均可追溯。" status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'} statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'} - meta={{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}} + meta={上海时间 · {data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}} actions={} />