diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index 0b023627..9760892e 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -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(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('真实协议来源概览'); expect(protocolCoverage).toHaveTextContent('真实来源覆盖'); expect(protocolCoverage).toHaveTextContent('32960'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 0453f36c..ab1b069d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -7,13 +7,13 @@ import { api } from '../../api/client'; import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; -import { MetricActionButton } from '../shared/MetricActionButton'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { TablePagination } from '../shared/TablePagination'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; +import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; 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 activeFilterCount = Object.values(criteria).filter(Boolean).length; const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]); + const accessMetric = ( + label: string, + value: number, + connectionState: string, + options: Pick + ): 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) => { if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth @@ -465,17 +493,17 @@ export default function AccessPage() { title={scopeLabels[criteria.connectionState] || '车辆接入清单'} description="缺席协议仅表示当前未发现;优先核对异常、离线与资料差异" actionsClassName="v2-access-table-actions" - actions={mobileLayout ? null : <>} + actions={mobileLayout ? null : } /> - +
{ } 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-metric-rail"'); expect(corePageSources.AccessPage).not.toContain('className="v2-access-kpis-card-v3"'); expect(corePageSources.AccessPage).toContain(' { expect(source).toContain('重新加载地图'); } - expect(corePageSources.AccessPage).toContain("from '../shared/MetricActionButton'"); - expect(corePageSources.AccessPage).toContain(' .semi-card-body { + 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%; - min-width: 0; - height: 48px; - border: 0; - border-radius: 0; - background: transparent; - padding: 0 14px; - color: #66778d; + display: grid; + grid-template-columns: auto repeat(3, minmax(0, 1fr)); + gap: 5px; } -.v2-access-status-tabs .v2-metric-action + .v2-metric-action::before { - position: absolute; - 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; +.v2-access-metric-rail .v2-access-protocol-coverage > small { + align-self: center; 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 { @@ -11623,34 +11611,41 @@ } .v2-access-status-tabs { - min-height: 44px; + min-height: 48px; } - .v2-access-status-tabs .v2-metric-action.semi-button { - height: 44px; - padding: 0 6px; + .v2-access-metric-rail.is-queue > .semi-card-body { + min-height: 48px; + grid-template-columns: minmax(0, 1fr); } - .v2-access-status-tabs .v2-metric-action + .v2-metric-action::before { - inset-block: 9px; + .v2-access-metric-rail .v2-workspace-metric-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .v2-access-status-tabs .v2-metric-action-content { - justify-content: center; - gap: 4px; + .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: 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; } - .v2-access-status-tabs strong { - margin-left: 0; - font-size: 13px; + .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content > strong { + font-size: 17px; + line-height: 19px; } - .v2-access-status-tabs em { - display: none; + .v2-access-metric-rail .v2-workspace-metric-action .semi-button-content > em { + font-size: 8px; } .v2-access-table-scroll-v3.is-mobile-scroll { @@ -20611,38 +20606,38 @@ min-height: 0; grid-column: 1; grid-row: 1; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: repeat(4, minmax(0, 1fr)); border-right: 1px solid #e4eaf2; - border-bottom: 0; } - .v2-access-status-tabs .v2-metric-action.semi-button { - height: auto; + .v2-access-metric-rail.is-queue.semi-card, + .v2-access-metric-rail.is-queue > .semi-card-body, + .v2-access-metric-rail .v2-workspace-metric-list { + height: 100%; 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; } - .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 { min-height: 0; grid-column: 2;