feat: unify access decision rail

This commit is contained in:
lingniu
2026-07-19 09:25:06 +08:00
parent 8372ca5349
commit 39c828ed32
4 changed files with 156 additions and 127 deletions

View File

@@ -57,6 +57,11 @@ test('removes old access rows immediately when the vehicle filter scope changes'
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument(); expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
} }
expect(screen.getByRole('navigation', { name: '接入差异筛选' })).toHaveClass('v2-access-status-tabs'); expect(screen.getByRole('navigation', { name: '接入差异筛选' })).toHaveClass('v2-access-status-tabs');
const accessMetrics = screen.getByRole('list', { name: '接入差异概览' });
expect(accessMetrics.closest('.v2-access-metric-rail')).toHaveClass('semi-card', 'is-queue');
expect(within(accessMetrics).getAllByRole('listitem')).toHaveLength(4);
expect(within(accessMetrics).getByRole('button', { name: '筛选需关注,共 0 辆' })).toHaveClass('v2-workspace-metric-action');
expect(within(accessMetrics).getByRole('button', { name: '筛选全部车辆,共 2 辆' })).toHaveAttribute('aria-pressed', 'true');
const protocolCoverage = screen.getByLabelText('真实协议来源概览'); const protocolCoverage = screen.getByLabelText('真实协议来源概览');
expect(protocolCoverage).toHaveTextContent('真实来源覆盖'); expect(protocolCoverage).toHaveTextContent('真实来源覆盖');
expect(protocolCoverage).toHaveTextContent('32960'); expect(protocolCoverage).toHaveTextContent('32960');

View File

@@ -7,13 +7,13 @@ import { api } from '../../api/client';
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton';
import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination'; import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet'; import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { detailTriggerRow } from '../shared/detailTriggerRow'; import { detailTriggerRow } from '../shared/detailTriggerRow';
@@ -365,6 +365,34 @@ export default function AccessPage() {
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data; const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
const activeFilterCount = Object.values(criteria).filter(Boolean).length; const activeFilterCount = Object.values(criteria).filter(Boolean).length;
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]); const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
const accessMetric = (
label: string,
value: number,
connectionState: string,
options: Pick<WorkspaceQueueMetricRailItem, 'note' | 'tone' | 'emphasis'>
): WorkspaceQueueMetricRailItem => {
const formattedValue = value.toLocaleString('zh-CN');
return {
label,
value: formattedValue,
...options,
active: criteria.connectionState === connectionState,
ariaLabel: `筛选${label},共 ${formattedValue}`,
onClick: () => apply({ ...criteria, connectionState })
};
};
const attentionVehicles = Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0));
const accessMetrics: WorkspaceQueueMetricRailItem[] = mobileLayout
? [
accessMetric('需关注', attentionVehicles, 'attention', { note: '异常与缺失', tone: 'warning', emphasis: 'primary' }),
accessMetric('全部车辆', summary?.totalVehicles ?? 0, '', { note: '权威主车辆', tone: 'neutral', emphasis: 'primary' })
]
: [
accessMetric('需关注', attentionVehicles, 'attention', { note: '异常、离线或缺失', tone: 'warning', emphasis: 'primary' }),
accessMetric('全部车辆', summary?.totalVehicles ?? 0, '', { note: '权威主车辆口径', tone: 'neutral', emphasis: 'primary' }),
accessMetric('尚无来源', summary?.neverReported ?? 0, 'not_connected', { note: '待核对设备映射', tone: 'warning', emphasis: 'secondary' }),
accessMetric('资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'master_data', { note: '品牌、车型或来源', tone: (summary?.masterDataIncompleteVehicles ?? 0) > 0 ? 'warning' : 'success', emphasis: 'secondary' })
];
const scrollAccessTable = (event: KeyboardEvent<HTMLDivElement>) => { const scrollAccessTable = (event: KeyboardEvent<HTMLDivElement>) => {
if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth
@@ -465,17 +493,17 @@ export default function AccessPage() {
title={scopeLabels[criteria.connectionState] || '车辆接入清单'} title={scopeLabels[criteria.connectionState] || '车辆接入清单'}
description="缺席协议仅表示当前未发现;优先核对异常、离线与资料差异" description="缺席协议仅表示当前未发现;优先核对异常、离线与资料差异"
actionsClassName="v2-access-table-actions" actionsClassName="v2-access-table-actions"
actions={mobileLayout ? null : <><ProtocolCoverage summary={summary} /><Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}></Button></>} actions={mobileLayout ? null : <Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}></Button>}
/> />
<nav className="v2-access-status-tabs" aria-label="接入差异筛选">{[ <nav className="v2-access-status-tabs" aria-label="接入差异筛选">
{ label: '主车辆', value: summary?.totalVehicles ?? 0, tone: 'all', connectionState: '', hint: '全部' }, <WorkspaceMetricRail
{ label: '需关注', value: Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), tone: 'attention', connectionState: 'attention' }, variant="queue"
{ label: '资料待维护', value: summary?.masterDataIncompleteVehicles ?? 0, tone: 'incomplete', connectionState: 'master_data' }, className="v2-access-metric-rail"
{ label: '尚无来源', value: summary?.neverReported ?? 0, tone: 'never', connectionState: 'not_connected' } ariaLabel="接入差异概览"
].map((item) => { items={accessMetrics}
const value = Number(item.value).toLocaleString('zh-CN'); context={!mobileLayout ? <ProtocolCoverage summary={summary} /> : undefined}
return <MetricActionButton key={item.label} label={item.label} value={value} hint={item.hint} tone={item.tone} active={criteria.connectionState === item.connectionState} ariaLabel={`筛选${item.label},共 ${value}`} onClick={() => apply({ ...criteria, connectionState: item.connectionState })} />; />
})}</nav> </nav>
<div <div
className={`v2-access-table-scroll-v3${mobileLayout ? ' is-mobile-scroll' : ''}`} className={`v2-access-table-scroll-v3${mobileLayout ? ' is-mobile-scroll' : ''}`}
tabIndex={0} tabIndex={0}

View File

@@ -241,6 +241,7 @@ describe('V2 production entry', () => {
} }
expect(corePageSources.AccessPage).toContain('className="v2-access-discovery-shell"'); expect(corePageSources.AccessPage).toContain('className="v2-access-discovery-shell"');
expect(corePageSources.AccessPage).toContain('className="v2-access-status-tabs"'); expect(corePageSources.AccessPage).toContain('className="v2-access-status-tabs"');
expect(corePageSources.AccessPage).toContain('className="v2-access-metric-rail"');
expect(corePageSources.AccessPage).not.toContain('className="v2-access-kpis-card-v3"'); expect(corePageSources.AccessPage).not.toContain('className="v2-access-kpis-card-v3"');
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-v3'); expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-v3');
expect(corePageSources.AccessPage).toContain('className="v2-access-detail-sidesheet"'); expect(corePageSources.AccessPage).toContain('className="v2-access-detail-sidesheet"');
@@ -573,8 +574,8 @@ describe('V2 production entry', () => {
expect(source).toContain('<MapRetryAction'); expect(source).toContain('<MapRetryAction');
expect(source).not.toContain('><IconRefresh />重新加载地图</button>'); expect(source).not.toContain('><IconRefresh />重新加载地图</button>');
} }
expect(corePageSources.AccessPage).toContain("from '../shared/MetricActionButton'"); expect(corePageSources.AccessPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.AccessPage).toContain('<MetricActionButton'); expect(corePageSources.AccessPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.AlertsPage).toContain("from '../shared/WorkspaceMetricRail'"); expect(corePageSources.AlertsPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.AlertsPage).toContain('<WorkspaceMetricRail'); expect(corePageSources.AlertsPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.HistoryPage).toContain('className="v2-history-result-meta"'); expect(corePageSources.HistoryPage).toContain('className="v2-history-result-meta"');

View File

@@ -11149,88 +11149,76 @@
} }
.v2-access-status-tabs { .v2-access-status-tabs {
display: grid; display: block;
min-height: 48px; min-height: 54px;
flex: 0 0 auto; flex: 0 0 auto;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden; overflow: hidden;
border-bottom: 1px solid #e4eaf2; background: #fff;
background: #fbfcfe;
} }
.v2-access-status-tabs .v2-metric-action.semi-button { .v2-access-metric-rail.is-queue > .semi-card-body {
position: relative; min-height: 54px;
grid-template-columns: minmax(0, 1fr) 292px;
}
.v2-access-metric-rail .v2-workspace-metric-list {
grid-template-columns: 1.12fr 1.12fr .94fr .94fr;
}
.v2-access-metric-rail .v2-workspace-metric-list > span,
.v2-access-metric-rail .v2-workspace-metric-action.semi-button,
.v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
min-height: 54px;
}
.v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
padding-block: 5px;
}
.v2-access-metric-rail .v2-workspace-metric-list > span.is-action.is-active.is-warning {
background: linear-gradient(145deg, #fff8ed 0%, #fffcf7 100%);
box-shadow: inset 0 -3px #e59a2f;
}
.v2-access-metric-rail .v2-workspace-metric-context {
padding: 6px 8px;
}
.v2-access-metric-rail .v2-access-protocol-coverage {
width: 100%; width: 100%;
min-width: 0; display: grid;
height: 48px; grid-template-columns: auto repeat(3, minmax(0, 1fr));
border: 0; gap: 5px;
border-radius: 0;
background: transparent;
padding: 0 14px;
color: #66778d;
} }
.v2-access-status-tabs .v2-metric-action + .v2-metric-action::before { .v2-access-metric-rail .v2-access-protocol-coverage > small {
position: absolute; align-self: center;
inset: 10px auto 10px 0;
width: 1px;
background: #e1e7ef;
content: '';
}
.v2-access-status-tabs .v2-metric-action:hover {
background: #f4f8fe;
}
.v2-access-status-tabs .v2-metric-action.is-active {
z-index: 1;
background: #eef5ff;
color: var(--v2-blue);
box-shadow: inset 0 -3px var(--v2-blue);
}
.v2-access-status-tabs .semi-button-content,
.v2-access-status-tabs .v2-metric-action-content {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
}
.v2-access-status-tabs .v2-metric-action-content {
gap: 8px;
}
.v2-access-status-tabs small {
overflow: hidden;
color: inherit;
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-access-status-tabs strong {
margin-left: auto;
color: #304157;
font-size: 16px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.v2-access-status-tabs .is-attention strong,
.v2-access-status-tabs .is-incomplete strong {
color: #c46b0d;
}
.v2-access-status-tabs .is-never strong {
color: #67778b;
}
.v2-access-status-tabs em {
color: #8b98aa;
font-size: 9px; font-size: 9px;
font-style: normal; }
.v2-access-metric-rail .v2-access-protocol-coverage > span {
min-height: 38px;
display: grid;
justify-items: center;
align-content: center;
gap: 1px;
border-color: #dce6f1;
background: #fff;
padding: 3px 5px;
}
.v2-access-metric-rail .v2-access-protocol-coverage b {
display: block;
color: #49617d;
font-size: 9px;
line-height: 12px;
}
.v2-access-metric-rail .v2-access-protocol-coverage em {
color: #2f5f9b;
font-size: 10px;
font-weight: 650;
line-height: 13px;
} }
.v2-access-table-v3 .semi-table-tbody > .semi-table-row > .semi-table-row-cell { .v2-access-table-v3 .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
@@ -11623,34 +11611,41 @@
} }
.v2-access-status-tabs { .v2-access-status-tabs {
min-height: 44px; min-height: 48px;
} }
.v2-access-status-tabs .v2-metric-action.semi-button { .v2-access-metric-rail.is-queue > .semi-card-body {
height: 44px; min-height: 48px;
padding: 0 6px; grid-template-columns: minmax(0, 1fr);
} }
.v2-access-status-tabs .v2-metric-action + .v2-metric-action::before { .v2-access-metric-rail .v2-workspace-metric-list {
inset-block: 9px; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.v2-access-status-tabs .v2-metric-action-content { .v2-access-metric-rail .v2-workspace-metric-list > span,
justify-content: center; .v2-access-metric-rail .v2-workspace-metric-action.semi-button,
gap: 4px; .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
min-height: 48px;
} }
.v2-access-status-tabs small { .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
grid-template-columns: auto minmax(0, 1fr);
column-gap: 6px;
padding: 4px 10px;
}
.v2-access-metric-rail .v2-workspace-metric-action .semi-button-content > small {
font-size: 9px; font-size: 9px;
} }
.v2-access-status-tabs strong { .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content > strong {
margin-left: 0; font-size: 17px;
font-size: 13px; line-height: 19px;
} }
.v2-access-status-tabs em { .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content > em {
display: none; font-size: 8px;
} }
.v2-access-table-scroll-v3.is-mobile-scroll { .v2-access-table-scroll-v3.is-mobile-scroll {
@@ -20611,38 +20606,38 @@
min-height: 0; min-height: 0;
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(4, minmax(0, 1fr));
border-right: 1px solid #e4eaf2; border-right: 1px solid #e4eaf2;
border-bottom: 0;
} }
.v2-access-status-tabs .v2-metric-action.semi-button { .v2-access-metric-rail.is-queue.semi-card,
height: auto; .v2-access-metric-rail.is-queue > .semi-card-body,
.v2-access-metric-rail .v2-workspace-metric-list {
height: 100%;
min-height: 0; min-height: 0;
}
.v2-access-metric-rail .v2-workspace-metric-list {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.v2-access-metric-rail .v2-workspace-metric-list > span + span {
border-top: 1px solid #e4eaf2;
border-left: 0;
}
.v2-access-metric-rail .v2-workspace-metric-list > span,
.v2-access-metric-rail .v2-workspace-metric-action.semi-button,
.v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
height: 100%;
min-height: 0;
}
.v2-access-metric-rail .v2-workspace-metric-action .semi-button-content {
grid-template-columns: auto 1fr;
padding: 3px 8px; padding: 3px 8px;
} }
.v2-access-status-tabs .v2-metric-action + .v2-metric-action::before {
inset: 0 8px auto;
width: auto;
height: 1px;
}
.v2-access-status-tabs .v2-metric-action-content {
justify-content: flex-start;
gap: 5px;
}
.v2-access-status-tabs small {
font-size: 8px;
}
.v2-access-status-tabs strong {
margin-left: auto;
font-size: 12px;
}
.v2-access-table-scroll-v3.is-mobile-scroll { .v2-access-table-scroll-v3.is-mobile-scroll {
min-height: 0; min-height: 0;
grid-column: 2; grid-column: 2;