From 59f220885bcee812b40d2d81fef7d703e642c35b Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 10:20:18 +0800 Subject: [PATCH] feat(vehicle): live-update single vehicle map --- .../web/src/v2/pages/VehiclePage.test.tsx | 69 +++++++++++++++++++ .../apps/web/src/v2/pages/VehiclePage.tsx | 19 +++-- 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx 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 new file mode 100644 index 00000000..7ebae573 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -0,0 +1,69 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { api } from '../../api/client'; +import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; +import VehiclePage from './VehiclePage'; + +const fleetMapVehicles = vi.hoisted(() => vi.fn()); + +vi.mock('../map/FleetMap', () => ({ + FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => { + fleetMapVehicles(vehicles); + return
; + } +})); + +vi.mock('../auth/AuthGate', () => ({ + usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } }) +})); + +const initialRealtime = { + vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocols: ['JT808'], sourceStatus: [], + sourceCount: 1, onlineSourceCount: 1, online: true, bindingStatus: 'bound', primaryProtocol: 'JT808', + longitude: 113.26, latitude: 23.13, speedKmh: 20, socPercent: 80, totalMileageKm: 1000, + lastSeen: '2026-07-16 10:00:00', reportIntervalMs: 30_000 +} satisfies VehicleRealtimeRow; + +const detail = { + vin: initialRealtime.vin, + lookupKey: initialRealtime.vin, + lookupResolved: true, + realtimeSummary: initialRealtime, + sources: ['JT808'], + sourceStatus: [], + realtime: [], + history: { items: [], total: 0, limit: 20, offset: 0 }, + raw: { items: [], total: 0, limit: 20, offset: 0 }, + mileage: { items: [], total: 0, limit: 20, offset: 0 }, + quality: { items: [], total: 0, limit: 20, offset: 0 } +} satisfies VehicleDetail; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + fleetMapVehicles.mockReset(); +}); + +test('polls lightweight single-vehicle realtime data and passes its report interval to the map', async () => { + const movedRealtime = { ...initialRealtime, longitude: 113.28, latitude: 23.15, lastSeen: '2026-07-16 10:00:30' }; + vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); + const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 }); + vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render(} />); + + await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1)); + const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] }); + expect((realtimeQuery?.options as { refetchInterval?: number }).refetchInterval).toBe(10_000); + const params = realtimeSpy.mock.calls[0][0]; + expect(params).toBeDefined(); + expect(params!.get('keywords')).toBe(initialRealtime.vin); + expect(params!.get('limit')).toBe('1'); + await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([ + expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 }) + ])); +}); 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 14de0b0b..67485845 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -6,7 +6,7 @@ import { import { FormEvent, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types'; +import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; @@ -23,6 +23,7 @@ function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; } function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; } const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const; +const SINGLE_VEHICLE_REFRESH_MS = 10_000; function ProfileSyncPanel({ onClose }: { onClose: () => void }) { const [sourceSystem, setSourceSystem] = useState(''); @@ -170,9 +171,9 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon ; } -function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) { +function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) { const { session } = usePlatformSession(); - const realtime = detail.realtimeSummary; + const realtime = liveRealtime ?? detail.realtimeSummary; const identity = detail.identity; const mapVehicles = realtime ? [realtime] : []; const lastMileage = detail.mileage.items[0]; @@ -198,10 +199,20 @@ function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, on export default function VehiclePage() { const { vin } = useParams(); const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime }); + const resolvedVin = query.data?.lookupResolved ? query.data.vin : ''; + const realtime = useQuery({ + queryKey: ['vehicle-detail-realtime', resolvedVin], + queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keywords: resolvedVin, limit: '1', offset: '0' }), signal), + enabled: Boolean(resolvedVin), + staleTime: 5_000, + gcTime: QUERY_MEMORY.highVolumeGcTime, + refetchInterval: resolvedVin ? SINGLE_VEHICLE_REFRESH_MS : false, + ...LIVE_QUERY_POLICY + }); const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY }); if (!vin) return ; if (query.isPending) return ; if (query.isError) return
query.refetch()} />
; if (!query.data.lookupResolved) return

未找到车辆

没有匹配“{vin}”的车牌、VIN 或终端记录。

重新查询
; - return { void query.refetch(); void telemetry.refetch(); }} />; + return { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />; }