refine Semi UI history workspace

This commit is contained in:
lingniu
2026-07-18 10:39:00 +08:00
parent 3cd19b276d
commit bba6a621aa
4 changed files with 151 additions and 24 deletions

View File

@@ -5,7 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import HistoryPage from './HistoryPage';
import HistoryPage, { formatHistoryDateTime } from './HistoryPage';
const mocks = vi.hoisted(() => ({
historyMetricCatalog: vi.fn(),
@@ -54,6 +54,12 @@ function renderPage(initialEntry = '/history?keywords=OLDVIN&dateFrom=2026-07-16
return { ...view, client };
}
test('renders protocol timestamps in Asia/Shanghai while preserving local values', () => {
expect(formatHistoryDateTime('2026-07-18T02:33:00Z')).toBe('2026-07-18 10:33:00');
expect(formatHistoryDateTime('2026-07-18T02:33:00+08:00')).toBe('2026-07-18 02:33:00');
expect(formatHistoryDateTime('2026-07-18 02:33:00')).toBe('2026-07-18 02:33:00');
});
test('preserves the exact monitor return after changing history filters', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
@@ -113,6 +119,9 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
for (const className of ['v2-history-filter-card', 'v2-history-summary-rail', 'v2-history-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-history-discovery-shell')).toContainElement(screen.getByLabelText('历史数据查询操作'));
expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('遥测证据检索');
expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('上海时区');
expect(view.container.querySelector('.v2-history-summary-rail')).toHaveAttribute('aria-label', '历史数据查询统计信息');
expect(view.container.querySelector('.v2-history-summary-secondary.semi-card-group')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-history-summary-card.semi-card')).toHaveLength(4);
@@ -240,6 +249,7 @@ test('opens a working column visibility panel and preserves non-hideable identit
const view = renderPage();
expect(await screen.findByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-time')).toHaveTextContent('2026-07-16 04:00:00');
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-metric-scroll')).not.toBeInTheDocument();
expect(screen.getByText('1 / 2 字段').closest('.semi-tag')).toBeInTheDocument();

View File

@@ -10,6 +10,7 @@ import { downloadBlob } from '../domain/download';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
@@ -24,6 +25,16 @@ 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 '正常';
@@ -86,6 +97,15 @@ export function formatAxisTime(value: string) {
if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16);
return historyAxisTimeFormatter.format(parsed).replace('/', '-');
}
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', ' ');
}
function currentHistoryWindow() {
const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0');
const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
@@ -132,8 +152,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: row.deviceTime },
{ key: '服务时间', value: row.serverTime },
{ 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: <Tag color="blue" type="light" size="small">{row.protocol}</Tag> },
{ key: '数据质量', value: <HistoryQualityTag quality={row.quality} /> },
{ key: '质量原因', value: row.qualityReason || '平台未返回质量说明' }
@@ -183,8 +203,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 },
{ title: '服务时间', dataIndex: 'serverTime', width: 150 },
{ 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: '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 },
@@ -357,24 +377,35 @@ export default function HistoryPage() {
return <div className="v2-history-page">
<MonitorReturnBar />
<WorkspaceFilterPanel
className="v2-history-filter-card"
title="查询范围"
description="车辆、时间、数据类型与协议来源"
mobileSummary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={!filtersCollapsed}
status={keywords.length ? `${keywords.length}已选` : '待选择车辆'}
statusColor={keywords.length ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<fieldset className="v2-history-range"><legend></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</WorkspaceFilterPanel>
<div className="v2-history-discovery-shell">
<WorkspaceCommandBar
className="v2-history-command-bar"
ariaLabel="历史数据查询操作"
title="遥测证据检索"
description="按车辆、时间和协议核验可追溯的历史数据"
status={keywords.length ? `${keywords.length} · ${(result?.total ?? 0).toLocaleString('zh-CN')}` : '等待查询'}
statusColor={keywords.length ? 'blue' : 'grey'}
meta={<Typography.Text type="tertiary"> · 5 </Typography.Text>}
/>
<WorkspaceFilterPanel
className="v2-history-filter-card"
title="查询范围"
description="车辆、时间、数据类型与协议来源"
mobileSummary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={!filtersCollapsed}
status={keywords.length ? `${keywords.length} 辆已选` : '待选择车辆'}
statusColor={keywords.length ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<fieldset className="v2-history-range"><legend></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</WorkspaceFilterPanel>
</div>
{keywords.length ? <WorkspaceMetricRail
className="v2-history-summary-rail"
cardClassName="v2-history-summary-card"
@@ -409,7 +440,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>{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><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>
: <HistoryDataTable rows={resultRows} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{dataQuery.isPending && keywords.length
? <PanelLoading className="v2-history-loading" title="正在加载当前筛选范围的历史数据" description="按车辆、时间和协议整理可追溯证据。" />

View File

@@ -136,6 +136,8 @@ describe('V2 production entry', () => {
expect(corePageSources.HistoryPage).not.toContain('v2-history-context-card');
expect(corePageSources.HistoryPage).not.toContain('v2-history-metric-scroll');
expect(corePageSources.HistoryPage).toContain('<WorkspaceFilterPanel');
expect(corePageSources.HistoryPage).toContain('<WorkspaceCommandBar');
expect(corePageSources.HistoryPage).toContain('className="v2-history-discovery-shell"');
expect(corePageSources.HistoryPage).toContain('className="v2-history-filter-card"');
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-history-table-card');
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-history-evidence-descriptions"');

View File

@@ -11438,6 +11438,61 @@
font-variant-numeric: tabular-nums;
}
.v2-history-discovery-shell {
display: grid;
min-width: 0;
flex: 0 0 auto;
gap: 0;
overflow: visible;
border: 1px solid #dce4ee;
border-radius: 14px;
background: #fff;
box-shadow: 0 9px 28px rgba(31, 53, 80, .055);
}
.v2-history-discovery-shell > .v2-history-command-bar {
width: 100%;
min-height: 50px;
border: 0;
border-bottom: 1px solid #e6ecf3;
border-radius: 14px 14px 0 0;
box-shadow: none;
}
.v2-history-discovery-shell > .v2-history-filter-card.v2-workspace-filter-panel.semi-card {
width: 100%;
overflow: visible;
border: 0;
border-radius: 0 0 14px 14px;
box-shadow: none;
}
.v2-history-discovery-shell > .v2-history-filter-card > .semi-card-body > .v2-workspace-filter-heading {
display: none;
}
.v2-history-discovery-shell .v2-history-toolbar {
border-radius: 0 0 14px 14px;
background: #fbfcfe;
}
.v2-history-time {
color: inherit;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.v2-history-data-table .v2-history-time {
color: #40536a;
font-size: 11px;
font-weight: 560;
}
.v2-history-evidence-descriptions .v2-history-time {
color: #33485f;
font-weight: 650;
}
@media (max-width: 900px) and (min-width: 681px) {
.v2-history-summary-rail > .semi-card-body {
grid-template-columns: minmax(230px, .7fr) minmax(0, 1.6fr);
@@ -11449,6 +11504,35 @@
}
@media (max-width: 680px) {
.v2-history-discovery-shell {
display: contents;
}
.v2-history-discovery-shell > .v2-history-command-bar {
display: none;
}
.v2-history-discovery-shell > .v2-history-filter-card.v2-workspace-filter-panel.semi-card {
border: 1px solid #dce6f2;
border-radius: 10px;
box-shadow: var(--v2-shadow);
}
.v2-history-discovery-shell > .v2-history-filter-card > .semi-card-body > .v2-workspace-filter-heading {
display: flex;
}
.v2-history-discovery-shell .v2-history-toolbar {
border-radius: 0 0 10px 10px;
background: #fff;
}
.v2-history-mobile-card-content .v2-history-time {
color: #617288;
font-size: 10px;
font-weight: 650;
}
.v2-history-summary-rail.semi-card {
min-height: 132px;
overflow: visible;