diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
index 07e91207..a7da6faf 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
@@ -87,7 +87,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
-test('never labels the latest historical mileage row as today when today has no mileage evidence', () => {
+test('never labels the latest historical mileage row as today when today has no mileage evidence', async () => {
vehicleCardFixture.detail = {
sources: ['JT808'],
sourceStatus: [],
@@ -103,12 +103,12 @@ test('never labels the latest historical mileage row as today when today has no
fireEvent.click(screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }));
- const todayMetric = screen.getByText('今日里程').parentElement;
+ const todayMetric = (await screen.findByText('今日里程')).parentElement;
expect(todayMetric).toHaveTextContent('今日里程—');
expect(todayMetric).not.toHaveTextContent('134');
});
-test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
+test('starts without a selection and supports expand, collapse, reselection, and clear', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
@@ -147,6 +147,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle);
+ expect(await screen.findByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
expect(workspace).toHaveClass('is-detail-open');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
@@ -159,7 +160,13 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByText('身份、车型与来源覆盖')).toBeInTheDocument();
expect(screen.getByText('1/1 在线').closest('.semi-tag')).toBeInTheDocument();
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('平台接收时间');
- expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('主协议JT808');
+ expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('主协议JT/T 808');
+ expect(view.container.querySelector('.v2-report-summary .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
+ expect(view.container.querySelectorAll('.v2-detail-actions a')).toHaveLength(4);
+ expect(Array.from(view.container.querySelectorAll('.v2-detail-actions a')).map((item) => item.textContent)).toEqual([
+ '单车详情', '轨迹回放', '历史数据', '里程查询'
+ ]);
+ expect(view.container.querySelector('.v2-detail-protocols .v2-protocol-tag')).toHaveTextContent('JT/T 808');
expect(view.container.querySelector('.v2-metric-grid > div:first-child')).toHaveTextContent('速度42');
expect(view.container.querySelector('.v2-metric-grid > div:nth-child(4)')).toHaveTextContent('今日里程18.6km');
expect(view.container.querySelector('.v2-status-text')).toHaveClass('semi-tag');
@@ -196,7 +203,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(secondVehicle).toHaveClass('is-selected');
});
-test('restores URL-backed monitor context and carries it into every vehicle workflow', () => {
+test('restores URL-backed monitor context and carries it into every vehicle workflow', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const entry = '/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online&selectedVin=LTEST000000000001&detail=collapsed&zoom=13&bounds=113.100000%2C23.000000%2C113.400000%2C23.300000';
render();
@@ -209,6 +216,7 @@ test('restores URL-backed monitor context and carries it into every vehicle work
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
fireEvent.click(screen.getByRole('button', { name: '展开车辆详情' }));
+ expect(await screen.findByRole('navigation', { name: '车辆快捷操作' })).toBeInTheDocument();
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询']) {
const target = new URL(screen.getByRole('link', { name }).getAttribute('href')!, 'https://vehicle-platform.invalid');
const monitorReturn = target.searchParams.get('monitorReturn');
@@ -261,7 +269,8 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
- expect(screen.getByText('JT808')).toBeInTheDocument();
+ expect(screen.getByText('JT/T 808')).toBeInTheDocument();
+ expect(view.container.querySelector('.v2-monitor-protocol .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(screen.queryByText('推荐来源')).not.toBeInTheDocument();
expect(screen.getByText('当日里程')).toBeInTheDocument();
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
index fcd895b0..f8bba80e 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
@@ -1,25 +1,24 @@
import {
- IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
+ IconChevronLeft, IconClose, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
-import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { Link, useSearchParams } from 'react-router-dom';
+import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
-import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
+import { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
-import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
-import { formatTelemetryValue } from '../domain/telemetry';
+import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
import { useMobileLayout } from '../hooks/useMobileLayout';
-import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
+import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
-import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
+import { buildMonitorPath, parseMonitorRouteContext } from '../routing/monitorContext';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
@@ -28,6 +27,7 @@ const MONITOR_VIEW_ITEMS = [
{ key: 'map', label: '地图', icon: },
{ key: 'list', label: '列表', icon: }
] as const;
+const VehicleDetailCard = lazy(() => import('./MonitorVehicleDetailCard'));
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
@@ -116,7 +116,7 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? km/h : null}> },
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <>{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}{hasTodayMileage(row) ? km : null}> },
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <>{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}{hasRealtimeMileage(row) ? km : null}> },
- { title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) =>
{row.primaryProtocol || '未知'}{row.protocols.length > 1 ? +{row.protocols.length - 1} 路 : null}
},
+ { title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => {row.protocols.length > 1 ?
+{row.protocols.length - 1} 路 : null}
},
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? {row.longitude.toFixed(6)}
{row.latitude.toFixed(6)} : — },
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => }
], [onSelect]);
@@ -129,7 +129,7 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
{!mobile ? {loading ?
: null}{!loading && !rows.length ?
: null}
: null}
{mobile ? {rows.map((row) =>
{row.plate || '未绑定车牌'}{row.vin}
{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? km/h : null}} aria-label={`在地图中定位${row.plate || row.vin}`} onClick={() => onSelect(row.vin)}>地图
- - 当日里程
- {hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}
- 总里程
- {hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}
- 协议来源
- {row.primaryProtocol || '未知'}{row.protocols.length > 1 ? ` · ${row.protocols.length} 路` : ''}
- 经纬度
- {hasRealtimeLocation(row) ?
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)} : '—'} - 地理位置
+ - 当日里程
- {hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}
- 总里程
- {hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}
- 协议来源
- {row.protocols.length > 1 ? {row.protocols.length} 路 : null}
- 经纬度
- {hasRealtimeLocation(row) ?
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)} : '—'} - 地理位置
)}{loading ?
: null}{!loading && !rows.length ?
: null}
: null}
;
@@ -202,115 +202,6 @@ const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { v
const MemoFleetMap = memo(FleetMap);
-function VehicleDetailCard({
- vehicle,
- monitorReturn,
- onCollapse,
- onClear
-}: {
- vehicle: VehicleRealtimeRow;
- monitorReturn: string;
- onCollapse: () => void;
- onClear: () => void;
-}) {
- const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
- const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
- const detail = card.detail.data;
- const activeAlerts = card.activeAlerts.data;
- const telemetry = card.telemetry?.data;
- const address = card.address.data;
- const status = vehicleStatus(vehicle);
- // The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
- // Vehicle detail mileage is ordered by recency and may start with yesterday, so it
- // must never be used as a fallback for a metric explicitly labelled "今日里程".
- const dailyMileage = vehicle.todayMileageAvailable === true ? vehicle.todayMileageKm : undefined;
- const latestAlert = activeAlerts?.items[0];
- const mileageLabel = vehicle.primaryProtocol === 'JT808' ? 'GPS 总里程' : '仪表盘总里程';
- const encodedVin = encodeURIComponent(vehicle.vin);
- const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
- const workflowLinks = [
- ['单车详情', `/vehicles/${encodedVin}`],
- ['轨迹回放', `/tracks?vin=${encodedVin}`],
- ['历史数据', `/history?vin=${encodedVin}`],
- ['里程查询', `/statistics?vins=${encodedVin}`]
- ] as const;
- const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
- 'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
- 'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
- 'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
- ].includes(item.key)).slice(0, 9);
- return (
-
-
-
-
- } aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
- } aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
-
-
-
{vehicle.plate || '未绑定车牌'}{statusLabel(status)}
-
{vehicle.vin}
-
-
-
-
- {relativeFreshness(vehicle.lastSeen)}}
- />
-
- 平台接收时间{vehicle.lastSeen || '暂无上报'}
- 主协议{vehicle.primaryProtocol || '未知'}
- 接入来源{accessSource || '待补充'}
-
-
- - 坐标
- - 位置
- {hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}
-
-
-
- {vehicle.primaryProtocol || '未知协议'}}
- />
-
-
速度{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}
-
SOC{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}{hasRealtimeSOC(vehicle) ? '%' : ''}
-
{mileageLabel}
-
今日里程
-
状态{statusLabel(status)}
-
0 ? ' has-alerts' : ''}`}>当前告警{formatNumber(activeAlerts?.total ?? 0)}条
-
- {gbHighlights.length ? {gbHighlights.map((item) =>
{item.label}{formatTelemetryValue(item.value, item.displayValue)}{item.unit}
)}
: null}
-
-
- {vehicle.onlineSourceCount}/{vehicle.sourceCount} 在线}
- />
-
- - VIN
- {vehicle.vin}
- - 品牌 / 车型
- {[detail?.profile?.brandName, detail?.profile?.modelName].filter(Boolean).join(' / ') || vehicle.oem || '待补充'}
- - 可用协议
- {detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}
- - 接入供应商
- {detail?.profile?.accessProvider || '待补充'}
- - 来源覆盖
- {vehicle.onlineSourceCount}/{vehicle.sourceCount}
- - 告警状态
- {latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}
-
-
-
-
-
- );
-}
-
export default function MonitorPage() {
const [routeParams, setRouteParams] = useSearchParams();
const initialContextRef = useRef(parseMonitorRouteContext(routeParams));
@@ -520,12 +411,14 @@ export default function MonitorPage() {
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
/>
{selected && detailOpen ? (
-
+ 正在加载车辆详情
}>
+
+
) : null}
{selected && !detailOpen ? (
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorVehicleDetailCard.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorVehicleDetailCard.tsx
new file mode 100644
index 00000000..a2e6e87d
--- /dev/null
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorVehicleDetailCard.tsx
@@ -0,0 +1,142 @@
+import { IconChevronRight, IconClose } from '@douyinfe/semi-icons';
+import { Button, Card, Tag } from '@douyinfe/semi-ui';
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import type { VehicleRealtimeRow } from '../../api/types';
+import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
+import { protocolSourceLabel } from '../domain/protocols';
+import { formatTelemetryValue } from '../domain/telemetry';
+import { useMonitorVehicleCard } from '../hooks/useMonitorData';
+import { withMonitorReturn } from '../routing/monitorContext';
+import { ProtocolTag } from '../shared/ProtocolTag';
+import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
+import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
+
+function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
+ if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
+ return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
+ && Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
+ && (vehicle.longitude !== 0 || vehicle.latitude !== 0);
+}
+
+function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
+ return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
+}
+
+function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
+ return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
+}
+
+function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
+ return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
+}
+
+export default function MonitorVehicleDetailCard({
+ vehicle,
+ monitorReturn,
+ onCollapse,
+ onClear
+}: {
+ vehicle: VehicleRealtimeRow;
+ monitorReturn: string;
+ onCollapse: () => void;
+ onClear: () => void;
+}) {
+ const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
+ const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
+ const detail = card.detail.data;
+ const activeAlerts = card.activeAlerts.data;
+ const telemetry = card.telemetry?.data;
+ const address = card.address.data;
+ const status = vehicleStatus(vehicle);
+ // The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
+ // Vehicle detail mileage is ordered by recency and may start with yesterday, so it
+ // must never be used as a fallback for a metric explicitly labelled "今日里程".
+ const dailyMileage = vehicle.todayMileageAvailable === true ? vehicle.todayMileageKm : undefined;
+ const latestAlert = activeAlerts?.items[0];
+ const mileageLabel = vehicle.primaryProtocol === 'JT808' ? 'GPS 总里程' : '仪表盘总里程';
+ const encodedVin = encodeURIComponent(vehicle.vin);
+ const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
+ const accessSourceLabel = protocolSourceLabel(accessSource) || '来源待补充';
+ const availableProtocols = detail?.sources?.length ? detail.sources : vehicle.protocols;
+ const workflowLinks = [
+ ['单车详情', `/vehicles/${encodedVin}`],
+ ['轨迹回放', `/tracks?vin=${encodedVin}`],
+ ['历史数据', `/history?vin=${encodedVin}`],
+ ['里程查询', `/statistics?vins=${encodedVin}`]
+ ] as const;
+ const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
+ 'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
+ 'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
+ 'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
+ ].includes(item.key)).slice(0, 9);
+
+ return
+
+
+
+ } aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
+ } aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
+
+
+
{vehicle.plate || '未绑定车牌'}{statusLabel(status)}
+
{vehicle.vin}
+
+
+
+
+ {relativeFreshness(vehicle.lastSeen)}}
+ />
+
+
平台接收时间{vehicle.lastSeen || '暂无上报'}
+
主协议
+
接入来源{accessSourceLabel}
+
+
+ - 坐标
+ - 位置
- {hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}
+
+
+
+ }
+ />
+
+
速度{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}
+
SOC{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}{hasRealtimeSOC(vehicle) ? '%' : ''}
+
{mileageLabel}
+
今日里程
+
状态{statusLabel(status)}
+
0 ? ' has-alerts' : ''}`}>当前告警{formatNumber(activeAlerts?.total ?? 0)}条
+
+ {gbHighlights.length ? {gbHighlights.map((item) =>
{item.label}{formatTelemetryValue(item.value, item.displayValue)}{item.unit}
)}
: null}
+
+
+ {vehicle.onlineSourceCount}/{vehicle.sourceCount} 在线}
+ />
+
+ - VIN
- {vehicle.vin}
+ - 品牌 / 车型
- {[detail?.profile?.brandName, detail?.profile?.modelName].filter(Boolean).join(' / ') || vehicle.oem || '待补充'}
+ - 可用协议
- {availableProtocols.length ? availableProtocols.map((protocol) => ) : }
+ - 接入供应商
- {detail?.profile?.accessProvider || '待补充'}
+ - 来源覆盖
- {vehicle.onlineSourceCount}/{vehicle.sourceCount}
+ - 告警状态
- {latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}
+
+
+
+
+ ;
+}
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 0fc6a2fd..78ef0612 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -12,6 +12,7 @@ const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'Track
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
]));
const vehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleSearchWorkspace.tsx'), 'utf8');
+const monitorVehicleDetailSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/MonitorVehicleDetailCard.tsx'), 'utf8');
const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/ReconciliationCenter.tsx'), 'utf8');
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
@@ -103,10 +104,11 @@ describe('V2 production entry', () => {
expect(profileSyncPanelSource).not.toContain(' import('./MonitorVehicleDetailCard'))");
+ expect(monitorVehicleDetailSource).toContain(' .semi-card-body {
+ display: grid;
+ height: 100%;
+ min-height: 220px;
+ place-items: center;
+}
+.v2-detail-loading-card [role="status"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #718197;
+ font-size: 11px;
+}
.v2-detail-shell-header {
position: sticky;
z-index: 5;
@@ -9054,19 +9067,27 @@ button, a { -webkit-tap-highlight-color: transparent; }
font-size: 10px;
}
.v2-detail-actions {
- grid-template-columns: repeat(4,minmax(0,1fr));
- gap: 4px;
+ grid-template-columns: repeat(2,minmax(0,1fr));
+ gap: 6px;
padding: 0;
}
.v2-detail-actions a {
- height: 38px;
+ height: 40px;
min-width: 0;
- gap: 5px;
+ justify-content: space-between;
+ gap: 8px;
border-color: #e1e8f1;
border-radius: 8px;
background: #fff;
+ padding-inline: 11px;
color: #55667c;
- font-size: 10px;
+ font-size: 11px;
+}
+.v2-detail-actions a > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.v2-detail-actions a > svg {
flex: 0 0 auto;
@@ -9127,7 +9148,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
}
.v2-report-summary {
display: grid;
- grid-template-columns: minmax(0,1.45fr) minmax(0,.8fr) minmax(0,1fr);
+ grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr);
gap: 0;
overflow: hidden;
margin: 9px 10px 0;
@@ -9139,7 +9160,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
min-width: 0;
padding: 8px 9px;
}
-.v2-report-summary > span + span { border-left: 1px solid #e3eaf3; }
+.v2-report-summary > .is-time {
+ grid-column: 1 / -1;
+ border-bottom: 1px solid #e3eaf3;
+}
+.v2-report-summary > .is-source { border-left: 1px solid #e3eaf3; }
.v2-report-summary small,
.v2-report-summary strong,
.v2-report-summary b {
@@ -9152,10 +9177,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-report-summary strong {
margin-top: 4px;
color: #153f73;
- font-size: 12px;
+ font-size: 13px;
font-variant-numeric: tabular-nums;
}
.v2-report-summary b { margin-top: 4px; color: #345679; font-size: 10px; }
+.v2-report-summary .v2-protocol-tag { max-width: 100%; margin-top: 4px; }
.v2-detail-report .v2-detail-list { margin: 5px 10px 8px; }
.v2-detail-report .v2-detail-list > div { min-height: 24px; grid-template-columns: 44px minmax(0,1fr); align-items: center; padding-block: 3px; }
.v2-detail-report .v2-detail-list dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -9205,6 +9231,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-detail-section > .v2-detail-list > div:last-child {
border-bottom: 0;
}
+.v2-detail-protocols {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.v2-detail-protocols .v2-protocol-tag { max-width: 100%; }
@media (max-width: 760px) {
.v2-detail-shell-header {
@@ -9217,12 +9250,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
right: 15px;
}
.v2-detail-actions {
- overflow-x: auto;
- grid-template-columns: repeat(4,minmax(92px,1fr));
- overscroll-behavior-x: contain;
- scrollbar-width: none;
+ grid-template-columns: repeat(2,minmax(0,1fr));
}
- .v2-detail-actions::-webkit-scrollbar { display: none; }
.v2-detail-actions a {
height: 42px;
font-size: 11px;
@@ -9238,7 +9267,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-detail-section > .v2-workspace-panel-header .v2-workspace-panel-meta {
font-size: 10px;
}
- .v2-report-summary { grid-template-columns: minmax(0,1.35fr) minmax(0,.8fr) minmax(0,1fr); }
+ .v2-report-summary { grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr); }
.v2-report-summary strong { font-size: 13px; }
.v2-detail-list > div { font-size: 10px; }
.v2-metric-grid small { font-size: 9px; }
@@ -9361,9 +9390,17 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-mobile-cards dd code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-mobile-cards dd.is-today,
.v2-monitor-mobile-protocol { color: #1268df; font-weight: 750; }
+ .v2-monitor-mobile-protocol {
+ display: inline-flex;
+ max-width: 100%;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 5px;
+ }
+ .v2-monitor-mobile-protocol > small { color: #77869a; font-size: 9px; white-space: nowrap; }
.v2-monitor-mobile-cards > .v2-monitor-mobile-card.semi-card { contain-intrinsic-size: auto 190px; }
.v2-report-summary {
- grid-template-columns: minmax(0,1.3fr) minmax(0,.85fr) minmax(0,1fr);
+ grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr);
}
.v2-report-summary strong { font-size: 13px; }
.v2-report-summary b { font-size: 11px; }