unify Shanghai time across Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 10:51:55 +08:00
parent bba6a621aa
commit 5805635ccf
9 changed files with 116 additions and 43 deletions

View File

@@ -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('尚未上报');
});
});

View File

@@ -1,4 +1,16 @@
const zhNumberFormatters = new Map<number, Intl.NumberFormat>();
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', ' ');
}

View File

@@ -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 ? <><Descriptions className="v2-history-evidence-descriptions" align="left" size="small" data={[
{ key: '车牌', value: row.plate || '—' },
{ key: 'VIN', value: row.vin },
{ key: '设备时间', value: <time className="v2-history-time" dateTime={row.deviceTime} title={row.deviceTime}>{formatHistoryDateTime(row.deviceTime)}</time> },
{ key: '服务时间', value: <time className="v2-history-time" dateTime={row.serverTime} title={row.serverTime}>{formatHistoryDateTime(row.serverTime)}</time> },
{ key: '设备时间', value: <PlatformTime className="v2-history-time" value={row.deviceTime} /> },
{ key: '服务时间', value: <PlatformTime className="v2-history-time" value={row.serverTime} /> },
{ key: '数据来源', value: <Tag color="blue" type="light" size="small">{row.protocol}</Tag> },
{ key: '数据质量', value: <HistoryQualityTag quality={row.quality} /> },
{ 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) => <Checkbox checked={selectedRowID === row.id} onChange={() => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />
},
{ title: '设备时间', dataIndex: 'deviceTime', width: 150, render: (value: string) => <time className="v2-history-time" dateTime={value} title={value}>{formatHistoryDateTime(value)}</time> },
{ title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => <time className="v2-history-time" dateTime={value} title={value}>{formatHistoryDateTime(value)}</time> },
{ title: '设备时间', dataIndex: 'deviceTime', width: 150, render: (value: string) => <PlatformTime className="v2-history-time" value={value} /> },
{ title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => <PlatformTime className="v2-history-time" value={value} /> },
{ title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' },
{ title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => <span className="v2-history-vin" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 100 },
@@ -440,7 +428,7 @@ export default function HistoryPage() {
aria-label={mobileLayout ? '历史明细列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-history-mobile-list">{resultRows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time className="v2-history-time" dateTime={row.deviceTime} title={row.deviceTime}>{formatHistoryDateTime(row.deviceTime)}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
? <div className="v2-history-mobile-list">{resultRows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><PlatformTime className="v2-history-time" value={row.deviceTime} /><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <HistoryDataTable rows={resultRows} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{dataQuery.isPending && keywords.length
? <PanelLoading className="v2-history-loading" title="正在加载当前筛选范围的历史数据" description="按车辆、时间和协议整理可追溯证据。" />

View File

@@ -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: /重试/ }));

View File

@@ -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 <Tag className={`v2-ops-health-tag is-${status}`} color={color} type="light" size="small"><i />{children ?? statusLabel(status)}</Tag>;
}
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 <div className="v2-source-cell"><strong>{fmt(source.firstSeenAt)}</strong><small> {fmt(source.receivedAt || source.eventTime)}</small></div>;
return <div className="v2-source-cell"><strong><PlatformTime className="v2-ops-time" value={source.firstSeenAt} sourceZone="utc" /></strong><small> <PlatformTime className="v2-ops-time" value={source.receivedAt || source.eventTime} sourceZone="utc" /></small></div>;
}
function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) {
@@ -313,7 +308,7 @@ function SourceDiagnosticWorkspace() {
<Card className="v2-source-summary-card"><small></small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
<Card className="v2-source-summary-card"><small></small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
<Card className={`v2-source-summary-card${data.evidence.locationConflict ? ' is-warning' : ' is-ok'}`}><small></small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></Card>
<Card className="v2-source-summary-card"><small></small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></Card>
<Card className="v2-source-summary-card"><small></small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? <>{data.policy.updatedBy} · <PlatformTime className="v2-ops-time" value={data.policy.updatedAt} sourceZone="utc" /></> : '尚无人工调整'}</span></Card>
</div>
<Card className="v2-source-recommendation"><header><Tag color="blue" type="light" size="small"></Tag><span>{data.refreshHint}</span></header><p>{data.recommendationReason}</p></Card>
{!editable ? <div className="v2-source-readonly"><Tag color="orange" type="light" size="small"></Tag><span></span></div> : null}
@@ -321,7 +316,7 @@ function SourceDiagnosticWorkspace() {
? <SourceDiagnosticCards vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />
: <SourceDiagnosticTable vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />}
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · <PlatformTime className="v2-ops-time" value={item.changedAt} sourceZone="utc" /></em></li>)}</ol> : <p></p>}
</Card>
</> : null}
</Card>
@@ -356,7 +351,7 @@ export default function OperationsPage() {
description="集中处理自动对账问题,按需诊断单车来源,并核对服务与协议健康;所有结论均可追溯。"
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
meta={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
meta={<Typography.Text type="tertiary"> · {data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
actions={<Button theme="light" icon={<IconRefresh />} loading={health.isFetching || readiness.isFetching} onClick={refresh}></Button>}
/>
<nav className="v2-ops-navigation" aria-label="运维质量视图">

View File

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { ReconciliationIssue, ReconciliationSummary } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
@@ -170,12 +171,6 @@ function reviewGuidance(issue: ReconciliationIssue) {
}[issue.ruleCode] ?? '先核对原始证据、影响对象和时间范围,再选择结论并留下可追溯说明。';
}
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 ReconciliationTrend({ data, maxTrend, onClose }: {
data?: ReconciliationSummary;
maxTrend: number;
@@ -185,7 +180,7 @@ function ReconciliationTrend({ data, maxTrend, onClose }: {
<WorkspacePanelHeader
variant="compact"
title="近 30 天趋势"
meta={`最近运行 ${fmt(data?.lastRunAt)}`}
meta={<span> <PlatformTime className="v2-ops-time" value={data?.lastRunAt} sourceZone="utc" /></span>}
actions={<Button
className="v2-reconcile-trend-toggle"
theme="borderless"
@@ -237,7 +232,7 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
<section className="v2-reconcile-identity">
<div><small>车辆</small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
<div><small>来源</small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span>累计发现 {issue.occurrenceCount.toLocaleString('zh-CN')} 次</span></div>
<div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
<div><small>时间</small><strong><PlatformTime className="v2-ops-time" value={issue.lastSeenAt} sourceZone="utc" /></strong><span>首次 <PlatformTime className="v2-ops-time" value={issue.firstSeenAt} sourceZone="utc" /></span></div>
</section>
<section className="v2-reconcile-decision-guide">
<IconAlertTriangle />
@@ -265,7 +260,7 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
</Card>
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="处理履历" meta={`${issue.actions?.length ?? 0} `} />
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p>暂无处理记录。</p>}
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · <PlatformTime className="v2-ops-time" value={action.createdAt} sourceZone="utc" /></span></div></li>)}</ol> : <p>暂无处理记录。</p>}
</Card>
</div>
</Card>;
@@ -335,7 +330,7 @@ export default function ReconciliationCenter() {
{ title: '问题 / 等级', dataIndex: 'title', width: 286, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell v2-reconcile-issue-cell"><span><strong>{item.title}</strong><ReconciliationSeverityTag severity={item.severity} /></span><small>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</small></span> },
{ title: '车辆', dataIndex: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</small></span> },
{ title: '证据来源', dataIndex: 'protocolA', width: 164, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{categoryLabel(item.category)}</small></span> },
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{fmt(item.lastSeenAt)}</strong><small>首次 {fmt(item.firstSeenAt)}</small></span> },
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></strong><small>首次 <PlatformTime className="v2-ops-time" value={item.firstSeenAt} sourceZone="utc" /></small></span> },
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
];
@@ -424,7 +419,7 @@ export default function ReconciliationCenter() {
<span className="v2-reconcile-mobile-content">
<header><strong>{item.plate || '平台级差异'}</strong><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span></header>
<p>{item.title}</p>
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span><time>{fmt(item.lastSeenAt)}</time></span>
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></span>
<footer><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span><span>证据与处置<IconChevronRight /></span></footer>
</span>
</Button>

View File

@@ -0,0 +1,19 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { PlatformTime } from './PlatformTime';
afterEach(cleanup);
test('renders a semantic Shanghai timestamp while retaining the source value', () => {
render(<PlatformTime value="2026-07-18 02:33:00" sourceZone="utc" />);
const time = screen.getByText('2026-07-18 10:33:00');
expect(time.tagName).toBe('TIME');
expect(time).toHaveAttribute('datetime', '2026-07-18 02:33:00');
expect(time).toHaveAttribute('title', '2026-07-18 02:33:00 · 上海时间');
});
test('keeps empty timestamps compact and non-semantic', () => {
render(<PlatformTime empty="尚未上报" />);
expect(screen.getByText('尚未上报').tagName).toBe('SPAN');
});

View File

@@ -0,0 +1,20 @@
import type { DateTimeSourceZone } from '../domain/formatters';
import { formatShanghaiDateTime } from '../domain/formatters';
export function PlatformTime({
value,
className = '',
empty = '—',
sourceZone = 'preserve'
}: {
value?: string;
className?: string;
empty?: string;
sourceZone?: DateTimeSourceZone;
}) {
const classes = ['v2-platform-time', className].filter(Boolean).join(' ');
if (!value?.trim()) return <span className={classes}>{empty}</span>;
return <time className={classes} dateTime={value} title={`${value} · 上海时间`}>
{formatShanghaiDateTime(value, { empty, sourceZone })}
</time>;
}

View File

@@ -11482,6 +11482,18 @@
white-space: nowrap;
}
.v2-platform-time {
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
letter-spacing: -.01em;
white-space: nowrap;
}
.v2-ops-time {
color: inherit;
}
.v2-history-data-table .v2-history-time {
color: #40536a;
font-size: 11px;