From 9d9185790f9ba4df3e68ed70e1378165ba21aed1 Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 20 Jul 2026 10:54:10 +0800 Subject: [PATCH] fix(web): harden monitor protocol list --- .../web/src/v2/pages/MonitorPage.test.tsx | 71 +++++++++++++++---- .../apps/web/src/v2/pages/MonitorPage.tsx | 46 ++++++++++-- .../apps/web/src/v2/styles/workspace.css | 19 +++-- 3 files changed, 103 insertions(+), 33 deletions(-) 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 987a8b92..88293f2c 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 @@ -37,6 +37,22 @@ const monitorSummaryFixture = vi.hoisted(() => ({ const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown })); const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); +function mockMobileViewport() { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + media: '(max-width: 760px)', + onchange: null, + addEventListener, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => false) + } as unknown as MediaQueryList); + return { addEventListener, removeEventListener }; +} + vi.mock('../map/FleetMap', () => ({ FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => { fleetMapRenderSpy(selectedVin, initialViewport); @@ -384,6 +400,21 @@ test('keeps authorized vehicles without realtime coordinates in the list without expect(reverseGeocode).not.toHaveBeenCalled(); }); +test('keeps the desktop realtime list available when a runtime response omits protocols', async () => { + const runtimeRow = { + ...vehicles[0], + protocols: null, + primaryProtocol: '' + } as unknown as VehicleRealtimeRow; + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [runtimeRow], total: 1, limit: 50, offset: 0 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect(await screen.findByText('粤A12345')).toBeInTheDocument(); + expect(screen.getByText('待识别')).toBeInTheDocument(); + expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument(); +}); + test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => { vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -446,18 +477,7 @@ test('shows a retry action when on-demand QR generation fails', async () => { }); test('mounts only the mobile list representation and removes its viewport listener', async () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - matches: true, - media: '(max-width: 760px)', - onchange: null, - addEventListener, - removeEventListener, - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(() => false) - } as unknown as MediaQueryList); + const { addEventListener, removeEventListener } = mockMobileViewport(); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 }); monitorSummaryFixture.frameToday = 2_861_323; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -479,11 +499,11 @@ test('mounts only the mobile list representation and removes its viewport listen await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1)); const mobileCard = view.container.querySelector('.v2-monitor-mobile-card'); expect(mobileCard).toHaveAttribute('aria-label', '粤A12345 实时数据'); - expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('当日里程18.6 km总里程1,234 km协议来源JT/T 808'); - expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(3); + expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('速度42 km/h当日里程18.6 km总里程1,234 km协议来源JT/T 808'); + expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(4); expect(mobileCard?.querySelector('.v2-monitor-mobile-location')).toHaveTextContent('113.260000, 23.130000解析位置'); expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('地图'); + expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('定位'); expect(screen.getByTitle('今日上报 2,861,323 条')).toHaveTextContent('今日上报286.1万条'); expect(view.container.querySelector('.v2-monitor-mobile-card footer')).not.toBeInTheDocument(); expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); @@ -492,6 +512,27 @@ test('mounts only the mobile list representation and removes its viewport listen expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); }); +test('renders missing and multiple runtime protocol sources on mobile without crashing', async () => { + mockMobileViewport(); + const runtimeRows = [{ + ...vehicles[0], + protocols: null, + primaryProtocol: '' + }, { + ...vehicles[1], + protocols: ['GB32960', 'JT808'], + primaryProtocol: 'GB32960' + }] as unknown as VehicleRealtimeRow[]; + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: runtimeRows, total: 2, limit: 50, offset: 0 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render(); + + await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-card')).toHaveLength(2)); + expect(screen.getByText('待识别')).toBeInTheDocument(); + expect(screen.getByText('2 路')).toBeInTheDocument(); + expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument(); +}); + test('pastes, deduplicates, and submits multiple plates as one batch search', async () => { const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); 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 fd03a282..2e19ee8d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -89,6 +89,33 @@ function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtim return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null; } +function vehicleProtocols(vehicle: VehicleRealtimeRow): string[] { + const runtimeValue = (vehicle as VehicleRealtimeRow & { protocols?: unknown }).protocols; + const normalized: string[] = []; + const seen = new Set(); + if (Array.isArray(runtimeValue)) { + for (const value of runtimeValue) { + if (typeof value !== 'string') continue; + const protocol = value.trim(); + if (!protocol || seen.has(protocol)) continue; + seen.add(protocol); + normalized.push(protocol); + } + } + const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : ''; + if (!normalized.length && primaryProtocol) normalized.push(primaryProtocol); + return normalized; +} + +function vehiclePrimaryProtocol(vehicle: VehicleRealtimeRow, sourceProtocols = vehicleProtocols(vehicle)) { + const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : ''; + return primaryProtocol || sourceProtocols[0] || ''; +} + +function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) { + return vehicleProtocols(vehicle).join('|'); +} + function formatSupportCount(value: number, compact: boolean) { if (!compact) return formatNumber(value); const absolute = Math.abs(value); @@ -142,18 +169,20 @@ type MonitorMobileVehicleCardProps = { const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, onSelect }: MonitorMobileVehicleCardProps) { const identity = row.plate || '未绑定车牌'; const location = hasRealtimeLocation(row); + const sourceProtocols = vehicleProtocols(row); + const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols); return
{identity}{row.vin}
- {hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? km/h : null} - +
-
当日里程
{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}
-
总里程
{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}
-
协议来源
{row.protocols.length > 1 ? {row.protocols.length} 路 : null}
+
速度
{hasRealtimeSpeed(row) ? <>{formatNumber(row.speedKmh, 1)} km/h : '—'}
+
当日里程
{hasTodayMileage(row) ? <>{formatNumber(row.todayMileageKm, 1)} km : '—'}
+
总里程
{hasRealtimeMileage(row) ? <>{formatNumber(row.totalMileageKm, 1)} km : '—'}
+
协议来源
{sourceProtocols.length > 1 ? {sourceProtocols.length} 路 : null}
@@ -173,7 +202,7 @@ const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, o && before.mileageAvailable === after.mileageAvailable && before.totalMileageKm === after.totalMileageKm && before.primaryProtocol === after.primaryProtocol - && before.protocols.join('|') === after.protocols.join('|') + && vehicleProtocolSignature(before) === vehicleProtocolSignature(after) && before.locationAvailable === after.locationAvailable && before.longitude === after.longitude && before.latitude === after.latitude; @@ -185,7 +214,10 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, er { 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.protocols.length > 1 ? +{row.protocols.length - 1} 路 : null}
}, + { title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => { + const sourceProtocols = vehicleProtocols(row); + return
{sourceProtocols.length > 1 ? +{sourceProtocols.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]); 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 9f050ce4..7c8ce9f6 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -26869,17 +26869,8 @@ gap: 6px !important; } - .v2-monitor-card-heading-actions > b { - font-size: 15px; - font-weight: 740; - } - - .v2-monitor-card-heading-actions > b small { - font-size: 9px; - } - .v2-monitor-card-heading-actions > .v2-monitor-card-locate.semi-button { - width: 46px; + width: 52px; height: 28px; min-height: 28px; border-radius: 7px; @@ -26889,7 +26880,7 @@ .v2-monitor-mobile-cards .v2-monitor-mobile-primary { display: grid; - grid-template-columns: .9fr 1.08fr 1.12fr; + grid-template-columns: .72fr 1fr 1.08fr 1.2fr; gap: 0; margin: 0; border-top: 1px solid #edf1f5; @@ -26935,6 +26926,12 @@ white-space: nowrap; } + .v2-monitor-mobile-cards .v2-monitor-mobile-primary dd > small { + color: #8b99aa; + font-size: 8px; + font-weight: 600; + } + .v2-monitor-mobile-cards .v2-monitor-mobile-primary dd.is-today { color: #1268df; }