feat(web): unify data panel hierarchy

This commit is contained in:
lingniu
2026-07-20 03:22:30 +08:00
parent dfa2782db6
commit df03aaac6a
11 changed files with 209 additions and 4 deletions

View File

@@ -511,6 +511,8 @@ export default function AccessPage() {
<div className="v2-access-workspace-v3">
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="接入差异"
tone="primary"
title="接入差异清单"
description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'} · 缺席协议仅表示当前未发现`}
actionsClassName="v2-access-table-actions"

View File

@@ -349,6 +349,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
<div className={`v2-alert-workspace${selectedID && !mobileLayout ? ' is-inspector-open' : ''}`}>
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="实时队列"
tone="warning"
title="当前事件队列"
description={selectedID && !mobileLayout ? '已展开处置详情 · 点击其他事件快速切换' : '点击事件查看触发证据、处理进度与处置动作'}
meta={`${Number(sums?.unprocessed ?? 0).toLocaleString('zh-CN')} 条待处置 · 共 ${(events.data?.total ?? 0).toLocaleString('zh-CN')}`}

View File

@@ -623,6 +623,8 @@ export default function HistoryPage() {
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="证据结果"
tone="primary"
title="历史明细"
description={keywords.length ? `${(result?.total ?? 0).toLocaleString('zh-CN')} 条记录 · ${resultSummary?.vehicleCount ?? 0} 辆车辆 · ${summarySources}` : '选择车辆和时间范围后查询'}
meta={<span className="v2-history-result-meta"><Tag color="blue" type="light" size="small">{visibleMetrics.length} / {allMetrics.length} </Tag>{resultSummary ? <span className="v2-history-query-duration"> {resultSummary.queryDurationMs} ms</span> : null}</span>}

View File

@@ -570,6 +570,8 @@ export default function OperationsPage() {
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<Card className="v2-ops-overview" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="运行快照"
tone="health"
title="运行概览"
description="关键链路、基础设施与车辆覆盖的同一健康快照"
meta={<span className="v2-ops-overview-meta"><HealthTag status={overallStatus}>{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}</HealthTag><Typography.Text type="tertiary">15 </Typography.Text></span>}

View File

@@ -333,6 +333,8 @@ export default function ReconciliationCenter() {
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
eyebrow="自动对账"
tone="health"
title="质量问题队列"
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
meta="每日自动对账"

View File

@@ -762,6 +762,8 @@ export default function StatisticsPage() {
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="每日统计"
tone="primary"
title="车辆每日里程"
description={`${criteria.dateFrom}${criteria.dateTo}`}
meta={<span className="v2-workspace-result-meta"><Tag color="blue" type="light" size="small">{hasVehicles ? `${totalVehicles}` : `${displayVehicles.length} / ${totalVehicles}`}</Tag><span>{dates.length} </span></span>}

View File

@@ -73,6 +73,21 @@ describe('V2 production entry', () => {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceCommandBar'");
expect(corePageSources[name]).toContain('<WorkspaceCommandBar');
}
for (const [name, eyebrow, tone] of [
['StatisticsPage', '每日统计', 'primary'],
['AlertsPage', '实时队列', 'warning'],
['AccessPage', '接入差异', 'primary'],
['HistoryPage', '证据结果', 'primary'],
['OperationsPage', '运行快照', 'health']
]) {
expect(corePageSources[name]).toContain(`eyebrow="${eyebrow}"`);
expect(corePageSources[name]).toContain(`tone="${tone}"`);
}
expect(reconciliationSource).toContain('eyebrow="自动对账"');
expect(reconciliationSource).toContain('tone="health"');
expect(workspaceStyles).toContain('.v2-workspace-panel-header.has-eyebrow');
expect(workspaceStyles).toContain('.v2-workspace-panel-eyebrow.semi-typography');
expect(workspaceStyles).toContain('.v2-mileage-results .v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy');
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleSearchWorkspace'))");
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'");
expect(vehicleSearchSource).toContain('<WorkspaceCommandBar');

View File

@@ -23,4 +23,18 @@ describe('WorkspacePanelHeader', () => {
rerender(<WorkspacePanelHeader variant="inverted" title="粤A12345" description="07/17 11:30:00" meta="50%" />);
expect(screen.getByRole('heading', { name: '粤A12345', level: 5 }).closest('header')).toHaveClass('is-inverted');
});
test('adds one compact semantic context marker for primary data panels', () => {
render(<WorkspacePanelHeader
eyebrow="实时队列"
tone="warning"
title="当前事件队列"
description="点击事件查看触发证据"
meta="12 条待处置"
/>);
const header = screen.getByRole('heading', { name: '当前事件队列', level: 5 }).closest('header');
expect(header).toHaveClass('has-eyebrow', 'tone-warning');
expect(screen.getByText('实时队列')).toHaveClass('v2-workspace-panel-eyebrow', 'semi-typography');
});
});

View File

@@ -3,29 +3,42 @@ import type { ReactNode } from 'react';
const { Text, Title } = Typography;
export type WorkspacePanelTone = 'neutral' | 'primary' | 'warning' | 'authority' | 'health' | 'danger';
export function WorkspacePanelHeader({
title,
description,
eyebrow,
meta,
actions,
variant = 'default',
tone = 'neutral',
className = '',
actionsClassName = ''
}: {
title: ReactNode;
description?: ReactNode;
eyebrow?: ReactNode;
meta?: ReactNode;
actions?: ReactNode;
variant?: 'default' | 'compact' | 'inverted';
tone?: WorkspacePanelTone;
className?: string;
actionsClassName?: string;
}) {
const headerClassName = ['v2-workspace-panel-header', `is-${variant}`, className].filter(Boolean).join(' ');
const headerClassName = [
'v2-workspace-panel-header',
`is-${variant}`,
`tone-${tone}`,
eyebrow != null ? 'has-eyebrow' : '',
className
].filter(Boolean).join(' ');
const trailingClassName = ['v2-workspace-panel-actions', actionsClassName].filter(Boolean).join(' ');
return (
<header className={headerClassName}>
<div className="v2-workspace-panel-copy">
{eyebrow != null ? <Text className="v2-workspace-panel-eyebrow">{eyebrow}</Text> : null}
<Title heading={5}>{title}</Title>
{description ? <Text type="tertiary">{description}</Text> : null}
</div>

View File

@@ -7,6 +7,10 @@
*/
.v2-workspace-panel-header {
--v2-panel-accent: #708198;
--v2-panel-accent-soft: rgba(112, 129, 152, .08);
--v2-panel-chip-border: rgba(112, 129, 152, .16);
--v2-panel-chip-background: #f5f7fa;
display: flex;
min-height: 52px;
flex: 0 0 auto;
@@ -18,6 +22,47 @@
padding: 8px 14px;
}
.v2-workspace-panel-header.tone-primary {
--v2-panel-accent: #1268df;
--v2-panel-accent-soft: rgba(18, 104, 223, .08);
--v2-panel-chip-border: rgba(18, 104, 223, .16);
--v2-panel-chip-background: #edf5ff;
}
.v2-workspace-panel-header.tone-warning {
--v2-panel-accent: #d97706;
--v2-panel-accent-soft: rgba(217, 119, 6, .09);
--v2-panel-chip-border: rgba(217, 119, 6, .17);
--v2-panel-chip-background: #fff6e8;
}
.v2-workspace-panel-header.tone-authority {
--v2-panel-accent: #6656c8;
--v2-panel-accent-soft: rgba(102, 86, 200, .08);
--v2-panel-chip-border: rgba(102, 86, 200, .16);
--v2-panel-chip-background: #f4f1ff;
}
.v2-workspace-panel-header.tone-health {
--v2-panel-accent: #0f8f68;
--v2-panel-accent-soft: rgba(15, 143, 104, .08);
--v2-panel-chip-border: rgba(15, 143, 104, .16);
--v2-panel-chip-background: #eaf8f3;
}
.v2-workspace-panel-header.tone-danger {
--v2-panel-accent: #d9473f;
--v2-panel-accent-soft: rgba(217, 71, 63, .08);
--v2-panel-chip-border: rgba(217, 71, 63, .16);
--v2-panel-chip-background: #fff2f1;
}
.v2-workspace-panel-header.has-eyebrow {
background:
linear-gradient(90deg, var(--v2-panel-accent-soft) 0%, rgba(255, 255, 255, .94) 28%, #fff 100%);
box-shadow: inset 3px 0 var(--v2-panel-accent);
}
/*
* Shared Semi UI mobile filter sheet.
* Keep the same title hierarchy, scroll surface, condition card and action
@@ -2534,6 +2579,43 @@
gap: 3px;
}
.v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
align-items: center;
column-gap: 7px;
row-gap: 1px;
}
.v2-workspace-panel-eyebrow.semi-typography {
display: inline-flex;
min-height: 18px;
align-items: center;
grid-column: 1;
grid-row: 1;
border: 1px solid var(--v2-panel-chip-border);
border-radius: 999px;
background: var(--v2-panel-chip-background);
padding-inline: 6px;
color: var(--v2-panel-accent);
font-size: 8px;
font-weight: 750;
letter-spacing: .04em;
line-height: 16px;
white-space: nowrap;
}
.v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy > h5.semi-typography {
grid-column: 2;
grid-row: 1;
}
.v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy > .semi-typography:last-child:not(.v2-workspace-panel-eyebrow) {
grid-column: 1 / -1;
grid-row: 2;
}
.v2-workspace-panel-header > .v2-workspace-panel-copy {
align-items: flex-start;
justify-content: center;
@@ -15550,12 +15632,34 @@
}
.v2-mileage-results .v2-workspace-panel-copy {
width: 82px;
min-width: 82px;
max-width: 82px;
width: 108px;
min-width: 108px;
max-width: 108px;
flex: none;
}
.v2-mileage-results .v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto;
align-content: center;
justify-items: start;
row-gap: 2px;
}
.v2-mileage-results .v2-workspace-panel-header.has-eyebrow .v2-workspace-panel-eyebrow.semi-typography {
grid-column: 1;
grid-row: 1;
min-height: 16px;
padding-inline: 5px;
font-size: 7px;
line-height: 14px;
}
.v2-mileage-results .v2-workspace-panel-header.has-eyebrow > .v2-workspace-panel-copy > h5.semi-typography {
grid-column: 1;
grid-row: 2;
}
.v2-mileage-results .v2-workspace-panel-copy > h5.semi-typography {
width: auto;
overflow: visible;