diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx
index 4bb0c6ef..748b23e2 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx
@@ -83,12 +83,13 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(screen.getByLabelText('单车任务栏')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-record-command');
expect(screen.getByLabelText('单车任务栏')).toHaveTextContent('粤A12345');
expect(screen.getByLabelText('单车任务栏')).toHaveTextContent('LTEST000000000001');
- expect(screen.getByRole('list', { name: '车辆实时指标' })).toHaveClass('v2-live-grid');
- expect(commandCard?.querySelectorAll('.v2-live-grid > [role="listitem"]')).toHaveLength(6);
- expect(commandCard?.querySelector('.v2-live-grid > div:first-child')).toHaveTextContent('速度');
- expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(2)')).toHaveTextContent('SOC');
- expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(4)')).toHaveTextContent('当日里程');
- expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(5)')).toHaveTextContent('推荐来源JT/T 808');
+ expect(screen.getByRole('list', { name: '车辆实时指标' })).toHaveClass('v2-workspace-metric-list');
+ expect(commandCard?.querySelector('.v2-vehicle-live-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
+ expect(commandCard?.querySelectorAll('.v2-workspace-metric-list > [role="listitem"]')).toHaveLength(5);
+ expect(commandCard?.querySelector('.v2-workspace-metric-list > span:first-child')).toHaveTextContent('速度');
+ expect(commandCard?.querySelector('.v2-workspace-metric-list > span:nth-child(2)')).toHaveTextContent('SOC');
+ expect(commandCard?.querySelector('.v2-workspace-metric-list > span:nth-child(4)')).toHaveTextContent('当日里程');
+ expect(commandCard?.querySelector('.v2-vehicle-live-source-context')).toHaveTextContent('推荐来源JT/T 808');
expect(commandCard?.querySelector('.v2-live-primary-protocol')).toHaveClass('v2-protocol-tag', 'semi-tag-cyan-light');
expect(commandCard?.querySelector('.v2-identity-sources .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveFocus();
@@ -112,9 +113,9 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(locationSource).toHaveClass('semi-button', 'v2-map-source-link');
expect(locationSource.closest('.semi-card')).toHaveClass('v2-single-map-card');
const totalMileageSource = screen.getByRole('button', { name: '查看总里程全部来源' });
- expect(totalMileageSource).toHaveClass('semi-button', 'v2-live-source-link');
- expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-live-overview');
- expect(totalMileageSource.closest('.semi-card')).toBe(commandCard);
+ expect(totalMileageSource).toHaveClass('semi-button', 'v2-workspace-metric-action');
+ expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-vehicle-live-metric-rail');
+ expect(totalMileageSource.closest('.v2-live-overview')).toBe(commandCard);
fireEvent.click(locationSource);
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
});
@@ -445,7 +446,7 @@ test('shows unavailable live fields as dashes instead of fabricated zeroes', asy
const liveOverview = (await screen.findByText('最新上报')).closest('.semi-card')!;
for (const label of ['速度', 'SOC', '总里程', '当日里程']) {
- const metric = Array.from(liveOverview.querySelectorAll('.v2-live-grid > div')).find((item) => item.querySelector('small')?.textContent === label);
+ const metric = Array.from(liveOverview.querySelectorAll('.v2-workspace-metric-list > [role="listitem"]')).find((item) => item.querySelector('small')?.textContent === label);
expect(metric).toHaveTextContent('—');
expect(metric).not.toHaveTextContent('88.8');
expect(metric).not.toHaveTextContent(/^0/);
@@ -562,11 +563,11 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(document.querySelector('.v2-identity-sources .semi-tag-blue-light')).toHaveTextContent('GB/T 32960');
expect(document.querySelector('.v2-identity-sources .semi-tag-cyan-light')).toHaveTextContent('JT/T 808');
expect(document.querySelector('.v2-identity-sources .semi-tag-violet-light')).toHaveTextContent('宇通 MQTT');
- expect(document.querySelector('.v2-vehicle-command-card .v2-live-grid')).toHaveAttribute('aria-label', '车辆实时指标');
- expect(document.querySelectorAll('.v2-vehicle-command-card .v2-live-grid > [role="listitem"]')).toHaveLength(6);
- expect(document.querySelector('.v2-live-metric.is-speed')).toHaveTextContent('速度');
- expect(document.querySelector('.v2-live-metric.is-today-mileage')).toHaveTextContent('当日里程');
- expect(document.querySelector('.v2-live-metric.is-source-status')).toHaveTextContent('来源状态');
+ expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list')).toHaveAttribute('aria-label', '车辆实时指标');
+ expect(document.querySelectorAll('.v2-vehicle-command-card .v2-workspace-metric-list > [role="listitem"]')).toHaveLength(5);
+ expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:first-child')).toHaveTextContent('速度');
+ expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:nth-child(4)')).toHaveTextContent('当日里程');
+ expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:nth-child(5)')).toHaveTextContent('来源状态');
fireEvent.click(screen.getByRole('button', { name: '历史数据' }));
expect(await screen.findByTestId('mobile-history-route')).toBeInTheDocument();
});
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
index 726a3aa7..696c5e78 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
@@ -23,6 +23,7 @@ import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
+import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
@@ -466,6 +467,46 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: , to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: , to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
];
+ const liveMetrics: WorkspaceQueueMetricRailItem[] = [
+ {
+ label: '速度',
+ value: <>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}km/h>,
+ note: '实时车速',
+ tone: 'primary',
+ emphasis: 'primary'
+ },
+ {
+ label: 'SOC',
+ value: <>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}%>,
+ note: '剩余电量',
+ tone: 'success',
+ emphasis: 'secondary'
+ },
+ {
+ label: '总里程',
+ value: <>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}km>,
+ note: 推荐口径,
+ emphasis: 'secondary',
+ ariaLabel: '查看总里程全部来源',
+ onClick: () => setSourceEvidenceOpen(true)
+ },
+ {
+ label: '当日里程',
+ value: <>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}km>,
+ note: 今日累计,
+ tone: 'primary',
+ emphasis: 'primary',
+ ariaLabel: '查看当日里程全部来源',
+ onClick: () => setSourceEvidenceOpen(true)
+ },
+ {
+ label: '来源状态',
+ value: <>{realtime?.onlineSourceCount ?? 0}/ {detail.sourceStatus.length}>,
+ note: '在线来源 / 全部',
+ tone: (realtime?.onlineSourceCount ?? 0) > 0 ? 'success' : 'neutral',
+ emphasis: 'secondary'
+ }
+ ];
useLayoutEffect(() => {
const root = recordRootRef.current;
if (!root) return;
@@ -529,14 +570,17 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
description="关键运行指标与推荐数据来源"
meta={{realtime?.online ? '实时在线' : '当前离线'} · }
/>
-
-
速度{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}km/h实时车速
-
SOC{availableMetric(realtime?.socPercent, realtime?.socAvailable)}%剩余电量
-
总里程推荐口径
-
当日里程今日累计
-
推荐来源{protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}
-
来源状态{realtime?.onlineSourceCount ?? 0} / {detail.sourceStatus.length}在线来源 / 全部
-
+
+ 推荐来源
+
+ {protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}
+ }
+ />
diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
index f97cf367..5ee729e3 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -78,7 +78,10 @@ describe('V2 production entry', () => {
expect(vehicleSearchSource).toContain(' {
expect(workspaceStyles).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));');
expect(workspaceStyles).toContain('.v2-vehicle-record-v3 .v2-vehicle-command-card > .semi-card-body {');
expect(workspaceStyles).toContain('grid-template-columns: minmax(210px, .62fr) minmax(0, 1.38fr);');
- expect(workspaceStyles).toContain('.v2-vehicle-record-v3 .v2-vehicle-command-card .v2-live-grid {');
+ expect(workspaceStyles).toContain('.v2-vehicle-live-metric-rail.is-queue > .semi-card-body {');
+ expect(workspaceStyles).toContain('.v2-vehicle-live-metric-rail .v2-workspace-metric-list {');
expect(workspaceStyles).toContain('.v2-vehicle-mobile-actions-sidesheet .v2-vehicle-mobile-actions-list {');
expect(workspaceStyles).toContain('.v2-access-table-v3 > .semi-card-body {');
expect(workspaceStyles).toContain('grid-template-columns: 112px minmax(0, 1fr);');
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
index 27869e0b..7dbb82e0 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
@@ -22782,3 +22782,170 @@
height: 46px;
}
}
+
+/*
+ * Single-vehicle live metrics use the shared Semi queue rail.
+ * The desktop rail keeps operating evidence in one scan line; mobile keeps
+ * the four driver-facing metrics in a compact 2 × 2 block so the map arrives
+ * sooner without removing mileage source evidence.
+ */
+.v2-vehicle-live-metric-rail.is-queue.semi-card {
+ border-bottom-color: #dfe7f0;
+}
+
+.v2-vehicle-live-metric-rail.is-queue > .semi-card-body {
+ grid-template-columns: minmax(0, 1fr) 196px;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list {
+ grid-template-columns: .78fr .72fr 1.14fr 1.02fr .84fr;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span,
+.v2-vehicle-live-metric-rail .v2-workspace-metric-action.semi-button,
+.v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content {
+ min-height: 70px;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span,
+.v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content {
+ padding: 8px 12px;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-action {
+ display: block;
+ padding: 0;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span > strong,
+.v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content > strong {
+ overflow: hidden;
+ font-size: clamp(17px, 1.45vw, 21px);
+ text-overflow: ellipsis;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary > strong,
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary .v2-workspace-metric-action strong {
+ font-size: clamp(15px, 1.25vw, 18px);
+}
+
+.v2-vehicle-live-unit {
+ margin-left: 4px;
+ color: #8492a4;
+ font-size: 9px;
+ font-style: normal;
+ font-weight: 550;
+ letter-spacing: 0;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-primary .v2-vehicle-live-unit {
+ color: #6f91bf;
+}
+
+.v2-vehicle-live-action-note {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.v2-vehicle-live-action-note > .semi-icon {
+ flex: 0 0 auto;
+ font-size: 9px;
+}
+
+.v2-vehicle-live-metric-rail .v2-workspace-metric-context {
+ padding: 8px 12px;
+}
+
+.v2-vehicle-live-source-context {
+ align-items: center;
+}
+
+.v2-vehicle-live-source-context > strong {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+}
+
+.v2-vehicle-record-v3 .v2-vehicle-command-card .v2-vehicle-live-source-context .v2-live-primary-protocol.semi-tag {
+ min-height: 23px;
+ max-width: 100%;
+ margin: 0;
+ border-radius: 7px;
+ padding-inline: 7px;
+ font-size: 9px;
+}
+
+.v2-vehicle-live-source-context > em {
+ max-width: 172px;
+}
+
+@media (max-width: 900px) {
+ .v2-vehicle-live-metric-rail.is-queue > .semi-card-body {
+ grid-template-columns: minmax(0, 1fr) 164px;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content {
+ padding-inline: 9px;
+ }
+}
+
+@media (max-width: 680px) {
+ .v2-vehicle-live-metric-rail.is-queue > .semi-card-body {
+ min-height: 104px;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary {
+ display: grid;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary.is-action {
+ display: block;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span:nth-child(n + 5) {
+ display: none;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action.semi-button,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content {
+ min-height: 52px;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span:nth-child(odd) {
+ border-left: 0;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span:nth-child(n + 3) {
+ border-top: 1px solid #e5ebf2;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content {
+ padding: 5px 9px;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-action {
+ padding: 0;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span > strong,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content > strong,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary > strong,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span.is-secondary .v2-workspace-metric-action strong {
+ font-size: 16px;
+ line-height: 19px;
+ }
+
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-list > span > em,
+ .v2-vehicle-live-metric-rail .v2-workspace-metric-action .semi-button-content > em {
+ font-size: 7px;
+ }
+}