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"> <div className="v2-access-workspace-v3">
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}> <Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
eyebrow="接入差异"
tone="primary"
title="接入差异清单" title="接入差异清单"
description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'} · 缺席协议仅表示当前未发现`} description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'} · 缺席协议仅表示当前未发现`}
actionsClassName="v2-access-table-actions" 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' : ''}`}> <div className={`v2-alert-workspace${selectedID && !mobileLayout ? ' is-inspector-open' : ''}`}>
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}> <Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
eyebrow="实时队列"
tone="warning"
title="当前事件队列" title="当前事件队列"
description={selectedID && !mobileLayout ? '已展开处置详情 · 点击其他事件快速切换' : '点击事件查看触发证据、处理进度与处置动作'} description={selectedID && !mobileLayout ? '已展开处置详情 · 点击其他事件快速切换' : '点击事件查看触发证据、处理进度与处置动作'}
meta={`${Number(sums?.unprocessed ?? 0).toLocaleString('zh-CN')} 条待处置 · 共 ${(events.data?.total ?? 0).toLocaleString('zh-CN')}`} 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)} /> <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 }}> <Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
eyebrow="证据结果"
tone="primary"
title="历史明细" title="历史明细"
description={keywords.length ? `${(result?.total ?? 0).toLocaleString('zh-CN')} 条记录 · ${resultSummary?.vehicleCount ?? 0} 辆车辆 · ${summarySources}` : '选择车辆和时间范围后查询'} 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>} 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} {readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<Card className="v2-ops-overview" bodyStyle={{ padding: 0 }}> <Card className="v2-ops-overview" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
eyebrow="运行快照"
tone="health"
title="运行概览" title="运行概览"
description="关键链路、基础设施与车辆覆盖的同一健康快照" description="关键链路、基础设施与车辆覆盖的同一健康快照"
meta={<span className="v2-ops-overview-meta"><HealthTag status={overallStatus}>{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}</HealthTag><Typography.Text type="tertiary">15 </Typography.Text></span>} 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 }}> return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
className="v2-reconcile-heading" className="v2-reconcile-heading"
eyebrow="自动对账"
tone="health"
title="质量问题队列" title="质量问题队列"
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。" description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
meta="每日自动对账" 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} {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 }}> <Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
eyebrow="每日统计"
tone="primary"
title="车辆每日里程" title="车辆每日里程"
description={`${criteria.dateFrom}${criteria.dateTo}`} 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>} 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("from '../shared/WorkspaceCommandBar'");
expect(corePageSources[name]).toContain('<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(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleSearchWorkspace'))");
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'"); expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'");
expect(vehicleSearchSource).toContain('<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%" />); 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'); 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; const { Text, Title } = Typography;
export type WorkspacePanelTone = 'neutral' | 'primary' | 'warning' | 'authority' | 'health' | 'danger';
export function WorkspacePanelHeader({ export function WorkspacePanelHeader({
title, title,
description, description,
eyebrow,
meta, meta,
actions, actions,
variant = 'default', variant = 'default',
tone = 'neutral',
className = '', className = '',
actionsClassName = '' actionsClassName = ''
}: { }: {
title: ReactNode; title: ReactNode;
description?: ReactNode; description?: ReactNode;
eyebrow?: ReactNode;
meta?: ReactNode; meta?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
variant?: 'default' | 'compact' | 'inverted'; variant?: 'default' | 'compact' | 'inverted';
tone?: WorkspacePanelTone;
className?: string; className?: string;
actionsClassName?: 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(' '); const trailingClassName = ['v2-workspace-panel-actions', actionsClassName].filter(Boolean).join(' ');
return ( return (
<header className={headerClassName}> <header className={headerClassName}>
<div className="v2-workspace-panel-copy"> <div className="v2-workspace-panel-copy">
{eyebrow != null ? <Text className="v2-workspace-panel-eyebrow">{eyebrow}</Text> : null}
<Title heading={5}>{title}</Title> <Title heading={5}>{title}</Title>
{description ? <Text type="tertiary">{description}</Text> : null} {description ? <Text type="tertiary">{description}</Text> : null}
</div> </div>

View File

@@ -7,6 +7,10 @@
*/ */
.v2-workspace-panel-header { .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; display: flex;
min-height: 52px; min-height: 52px;
flex: 0 0 auto; flex: 0 0 auto;
@@ -18,6 +22,47 @@
padding: 8px 14px; 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. * Shared Semi UI mobile filter sheet.
* Keep the same title hierarchy, scroll surface, condition card and action * Keep the same title hierarchy, scroll surface, condition card and action
@@ -2534,6 +2579,43 @@
gap: 3px; 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 { .v2-workspace-panel-header > .v2-workspace-panel-copy {
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
@@ -15550,12 +15632,34 @@
} }
.v2-mileage-results .v2-workspace-panel-copy { .v2-mileage-results .v2-workspace-panel-copy {
width: 82px; width: 108px;
min-width: 82px; min-width: 108px;
max-width: 82px; max-width: 108px;
flex: none; 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 { .v2-mileage-results .v2-workspace-panel-copy > h5.semi-typography {
width: auto; width: auto;
overflow: visible; overflow: visible;

View File

@@ -151,4 +151,51 @@
- 首次截图在 SideSheet 进入动画期间截到不完整底栏;等待动画稳定后复核,重置与应用按钮完整可见。 - 首次截图在 SideSheet 进入动画期间截到不完整底栏;等待动画稳定后复核,重置与应用按钮完整可见。
- 同视口对比确认新增摘要没有压缩关键输入、日期范围和来源选择,未发现 P0、P1 或 P2 问题。 - 同视口对比确认新增摘要没有压缩关键输入、日期范围和来源选择,未发现 P0、P1 或 P2 问题。
## 共享数据面板标题 Semi UI Design QA
- source visual truth: ECS 生产端里程查询、告警中心、接入管理与运维质量结果面板
- source screenshots:
- statistics baseline: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/desktop-statistics-after.png`
- mobile statistics baseline: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/mobile-statistics-after.png`
- implementation screenshots:
- statistics desktop: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/desktop-statistics-fixed.png`
- statistics mobile: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/mobile-statistics-fixed.png`
- alert center desktop: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/desktop-alerts-fixed.png`
- access center desktop: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/desktop-access-fixed.png`
- operations desktop: `/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/.codex-audit/semi-panel-hierarchy-20260720/desktop-operations-fixed.png`
- viewport and state:
- mobile `390 × 844`
- desktop `1440 × 900`
- 管理员权限、生产数据、默认查询状态
### Fidelity Ledger
| Surface | Required fidelity | Implementation evidence | Result |
| --- | --- | --- | --- |
| Semantic hierarchy | 高频结果区必须先说明数据语境,再给出当前任务标题。 | 共享 `WorkspacePanelHeader` 新增真实眉题层;里程、告警、接入、历史、对账和运维分别使用“每日统计”“实时队列”“接入差异”等业务语义。 | Improved. |
| Tone consistency | 不同业务状态需要可辨认,但不能形成高饱和装饰色块。 | 使用共享 primary、warning、health 等低对比度语义变量,只在顶部细线、眉题与轻微渐变中表达状态。 | Passed. |
| Data density | 标题升级不能挤压表格、统计条和分页主体。 | `1440 × 900` 下四个生产结果页保留原表格行数、筛选条件、分页和滚动边界。 | Preserved. |
| Mobile hierarchy | 眉题、结果标题、车辆数量和操作不能在窄屏互相遮挡。 | 里程结果头在 `390px` 下缩短描述并收敛文本轨道;车辆数量、刷新和导出仍完整可见。 | Passed. |
| Action continuity | 刷新、导出和结果计数仍需保持原按钮语义与点击范围。 | 共享组件只调整标题结构,原 Semi Button、Tag、事件处理器和可访问名称均未替换。 | Preserved. |
| Shared implementation | 页面不能各自复制标题视觉规则。 | 六个页面复用同一 `WorkspacePanelHeader` 与统一 tone 类型,样式集中在 `workspace.css`。 | Passed. |
| Desktop stability | 共享视觉层不得产生横向溢出、截断或固定列错位。 | 里程、告警、接入和运维生产截图均无标题裁切,列表滚动条、固定列及分页位置正常。 | Passed. |
### Interaction And Accessibility Checks
- 结果标题继续输出正确的 Heading 层级,眉题使用 Semi Typography 文本而不是模拟标题;
- 里程查询的“刷新里程数据”和“导出 Excel”保留完整可访问名称
- 告警、接入和运维结果列表保留原筛选、刷新、分页与纵向滚动交互;
- `390 × 844` 下里程矩阵车牌列与区间总里程列继续固定,日期列可横向浏览;
- `1440 × 900` 下四个重点页面没有出现标题折断、操作区覆盖或列表主体缩水;
- 生产发布版本 `semi-panel-hierarchy-20260720031800` 完成资源烟测与登录态复核。
### Comparison History
#### Pass 1 — fixed
- 首轮移动端复核发现新增眉题、结果标题和车辆数量在同一窄轨道内显得拥挤。
- 通过共享标题的移动端两级文本布局和里程结果区专用宽度约束,减少无效描述占位,同时保留结果计数与操作。
- 第二轮同视口截图确认“每日统计—车辆每日里程—20 / 1024 辆”层级可辨,刷新、导出和首屏表格均可达。
- 桌面端里程、告警、接入与运维四个结果面板语义色克制、密度稳定,未发现 P0、P1 或 P2 视觉与交互问题。
final result: passed final result: passed