From e80e1dcea984ab10c4a700abf39e1fa58da1dcd5 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 00:59:57 +0800 Subject: [PATCH] perf(web): release monitor resources on unmount --- .../apps/web/src/integrations/amap.ts | 3 ++ .../v2/hooks/useMonitorData.cache.test.tsx | 51 ++++++++++++++++++- .../apps/web/src/v2/hooks/useMonitorData.ts | 19 ++++--- .../apps/web/src/v2/map/FleetMap.test.tsx | 25 ++++++++- .../apps/web/src/v2/map/FleetMap.tsx | 25 +++++++-- .../apps/web/src/v2/map/TrackMap.tsx | 7 ++- .../web/src/v2/pages/MonitorPage.test.tsx | 48 ++++++++++++++--- .../apps/web/src/v2/pages/MonitorPage.tsx | 42 +++++++++------ .../docs/frontend-production-readiness.md | 11 ++++ 9 files changed, 194 insertions(+), 37 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/integrations/amap.ts b/vehicle-data-platform/apps/web/src/integrations/amap.ts index 3776587d..4bf7cbb2 100644 --- a/vehicle-data-platform/apps/web/src/integrations/amap.ts +++ b/vehicle-data-platform/apps/web/src/integrations/amap.ts @@ -8,6 +8,7 @@ export type AMapMap = { setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void; destroy: () => void; on?: (eventName: string, handler: (event: unknown) => void) => void; + off?: (eventName: string, handler: (event: unknown) => void) => void; getZoom?: () => number; getBounds?: () => { getSouthWest?: () => { getLng?: () => number; getLat?: () => number }; @@ -22,6 +23,7 @@ export type AMapMap = { export type AMapOverlay = { setMap?: (map: AMapMap | null) => void; on?: (eventName: string, handler: () => void) => void; + off?: (eventName: string, handler: () => void) => void; setPosition?: (position: [number, number]) => void; setPath?: (path: [number, number][]) => void; }; @@ -31,6 +33,7 @@ export type AMapMassMarks = { setData: (data: AMapMassPoint[]) => void; setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void; on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void; + off?: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void; }; export type AMapLabelsLayer = { diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx index ff2463ff..5d64b4e3 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx @@ -1,9 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import { cleanup, render, renderHook, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import type { ReactNode } from 'react'; import { api } from '../../api/client'; -import { useMonitorData } from './useMonitorData'; +import { useMonitorData, useMonitorVehicleCard } from './useMonitorData'; afterEach(() => { cleanup(); @@ -33,3 +33,50 @@ test('rapid viewport changes retain at most one monitor map payload', async () = view.unmount(); client.clear(); }); + +test('repeated vehicle selections retain only the active realtime payload and release it on unmount', async () => { + vi.spyOn(api, 'monitorSummary').mockResolvedValue({ totalVehicles: 0, onlineVehicles: 0, offlineVehicles: 0, drivingVehicles: 0, idleVehicles: 0, unknownVehicles: 0, alertVehicles: 0, activeToday: 0, frameToday: 0, alertDataAvailable: false, truncated: false, asOf: '' }); + const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 1, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + const filters = { keyword: '', protocol: '', status: '' }; + const view = renderHook(({ vin }) => useMonitorData(filters, { zoom: 5, bounds: '' }, vin, false, false), { + wrapper, + initialProps: { vin: 'VIN-001' } + }); + + await waitFor(() => expect(vehicleRealtime).toHaveBeenCalledTimes(1)); + for (const vin of ['VIN-002', 'VIN-003', 'VIN-004']) { + view.rerender({ vin }); + await waitFor(() => expect(vehicleRealtime.mock.calls.length).toBeGreaterThanOrEqual(Number(vin.slice(-1)))); + } + await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['monitor', 'selected-vehicle'] })).toHaveLength(1)); + view.unmount(); + await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['monitor', 'selected-vehicle'] })).toHaveLength(0)); + client.clear(); +}); + +test('collapsed vehicle card does not issue or retain detail-only queries', async () => { + const detail = vi.spyOn(api, 'vehicleDetail').mockResolvedValue({} as never); + const alerts = vi.spyOn(api, 'alertEventsV2').mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); + const address = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '测试地址' }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const vehicle = { vin: 'VIN-001', longitude: 113.26, latitude: 23.13 } as never; + function ActiveCard() { + useMonitorVehicleCard('VIN-001', vehicle, true); + return null; + } + const view = render({null}); + + expect(detail).not.toHaveBeenCalled(); + expect(alerts).not.toHaveBeenCalled(); + expect(address).not.toHaveBeenCalled(); + view.rerender(); + await waitFor(() => expect(detail).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(alerts).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(address).toHaveBeenCalledTimes(1)); + view.rerender({null}); + await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['monitor', 'vehicle-card'] })).toHaveLength(0)); + view.unmount(); + client.clear(); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts index a71fbdaa..f8a10840 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../../api/client'; import type { VehicleRealtimeRow } from '../../api/types'; +import { QUERY_MEMORY } from '../queryPolicy'; export type MonitorFilters = { keyword: string; @@ -64,14 +65,16 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor const summary = useQuery({ queryKey: ['monitor', 'summary', params.toString()], queryFn: ({ signal }) => api.monitorSummary(params, signal), - refetchInterval: MONITOR_REFRESH.summary + refetchInterval: MONITOR_REFRESH.summary, + gcTime: QUERY_MEMORY.summaryGcTime }); const vehicles = useQuery({ queryKey: ['monitor', 'vehicles', params.toString()], queryFn: ({ signal }) => api.vehicleRealtime(params, signal), enabled: vehicleEnabled, placeholderData: (previous) => previous, - refetchInterval: MONITOR_REFRESH.fleet + refetchInterval: MONITOR_REFRESH.fleet, + gcTime: QUERY_MEMORY.highVolumeGcTime }); const map = useQuery({ @@ -89,7 +92,8 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' }), signal), enabled: Boolean(selectedVin), staleTime: 5_000, - refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false + refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false, + gcTime: QUERY_MEMORY.highVolumeGcTime }); return { summary, vehicles, map, selectedVehicle }; @@ -106,14 +110,16 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, queryKey: ['monitor', 'vehicle-card', 'detail', vin], queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal), enabled, - staleTime: 30_000 + staleTime: 30_000, + gcTime: QUERY_MEMORY.highVolumeGcTime }); const activeAlerts = useQuery({ queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin], queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal), enabled, staleTime: 10_000, - refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false + refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false, + gcTime: QUERY_MEMORY.highVolumeGcTime }); const address = useQuery({ queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude], @@ -122,7 +128,8 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, latitude: latitude!.toFixed(6) }), signal), enabled: enabled && hasCoordinate, - staleTime: 60 * 60_000 + staleTime: 60 * 60_000, + gcTime: QUERY_MEMORY.highVolumeGcTime }); return { detail, activeAlerts, address }; diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 43fd846e..cf96436f 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -13,6 +13,9 @@ const markerSetMap = vi.fn(); const markerSetPosition = vi.fn(); const setZoomAndCenter = vi.fn(); const panTo = vi.fn(); +const mapOff = vi.fn(); +const mapDestroy = vi.fn(); +const massOff = vi.fn(); const getZoom = vi.fn(() => 5); const getBounds = vi.fn((): ReturnType> => ({})); const mapHandlers = new Map void>(); @@ -26,8 +29,9 @@ class TestMap { } add = vi.fn(); addControl = vi.fn(); - destroy = vi.fn(); + destroy = mapDestroy; on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler)); + off = mapOff; getZoom = getZoom; getBounds = getBounds; setZoomAndCenter = setZoomAndCenter; @@ -36,6 +40,7 @@ class TestMap { class TestMassMarks { on = vi.fn(); + off = massOff; setMap = vi.fn(); setData = setData; setStyle = setStyle; @@ -145,6 +150,9 @@ afterEach(() => { markerSetPosition.mockReset(); setZoomAndCenter.mockReset(); panTo.mockReset(); + mapOff.mockReset(); + mapDestroy.mockReset(); + massOff.mockReset(); getZoom.mockReset(); getZoom.mockReturnValue(5); getBounds.mockReset(); @@ -187,6 +195,21 @@ test('renders data that arrives before the delayed AMap SDK is ready', async () expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+'); }); +test('detaches AMap listeners and destroys the map on unmount', async () => { + window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; + window.AMapLoader = { load: vi.fn(async () => amapMock()) }; + + const view = render( undefined} />); + await waitFor(() => expect(setData).toHaveBeenCalled()); + view.unmount(); + + expect(massOff).toHaveBeenCalledWith('click', expect.any(Function)); + expect(mapOff).toHaveBeenCalledWith('moveend', expect.any(Function)); + expect(mapOff).toHaveBeenCalledWith('zoomend', expect.any(Function)); + expect(mapOff).toHaveBeenCalledWith('dragstart', expect.any(Function)); + expect(mapDestroy).toHaveBeenCalledTimes(1); +}); + test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => { const [west, south] = wgs84ToGcj02(113, 22); const [east, north] = wgs84ToGcj02(114, 24); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 34e71b9f..891fa7e5 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -171,6 +171,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect let cancelled = false; let resizeTimer: number | undefined; let resizeObserver: ResizeObserver | undefined; + let mapInstance: AMapMap | undefined; + let massInstance: AMapMassMarks | undefined; + let selectMassPoint: ((event: { data: AMapMassPoint }) => void) | undefined; + let notifyViewport: (() => void) | undefined; + let stopFollowing: (() => void) | undefined; setState('loading'); loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { if (cancelled || !containerRef.current) return; @@ -186,13 +191,15 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect showLabel: true, resizeEnable: true }); + mapInstance = map; map.addControl(new AMap.Scale()); if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } })); const styles = [ ...COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) })) ]; const mass = new AMap.MassMarks([], { opacity: 0.96, zIndex: 120, cursor: 'pointer', style: styles, zooms: [3, 20] }); - mass.on('click', (event) => { + massInstance = mass; + selectMassPoint = (event: { data: AMapMassPoint }) => { const cluster = clustersRef.current.get(event.data.id); if (cluster) { map.setZoomAndCenter?.(Math.min(20, (map.getZoom?.() ?? 5) + 2), wgs84ToGcj02(cluster.longitude, cluster.latitude)); @@ -204,12 +211,13 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } const vehicle = vehiclesRef.current.get(event.data.id); if (vehicle) onSelectRef.current(vehicle); - }); + }; + mass.on('click', selectMassPoint); mass.setMap(map); const labels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [11, 18.99], zIndex: 110, collision: true, allowCollision: false }) : null; const denseLabels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }) : null; labels?.setMap(map); - const notifyViewport = () => { + notifyViewport = () => { setMapZoom(map.getZoom?.() ?? 5); window.clearTimeout(viewportTimerRef.current); viewportTimerRef.current = window.setTimeout(() => { @@ -219,11 +227,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect }; map.on?.('moveend', notifyViewport); map.on?.('zoomend', notifyViewport); - map.on?.('dragstart', () => { + stopFollowing = () => { if (!centeredVinRef.current) return; followSelectedRef.current = false; setFollowSelected(false); - }); + }; + map.on?.('dragstart', stopFollowing); mapRef.current = map; amapRef.current = AMap; massRef.current = mass; @@ -246,6 +255,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect resizeObserver?.disconnect(); window.clearTimeout(resizeTimer); window.clearTimeout(viewportTimerRef.current); + if (selectMassPoint) massInstance?.off?.('click', selectMassPoint); + if (notifyViewport) { + mapInstance?.off?.('moveend', notifyViewport); + mapInstance?.off?.('zoomend', notifyViewport); + } + if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); massRef.current?.setMap(null); labelsRef.current?.setMap(null); denseLabelsRef.current?.setMap(null); diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx index 7a779329..5a763c58 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx @@ -35,6 +35,8 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele useEffect(() => { if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; } let cancelled = false; + let mapInstance: AMapMap | undefined; + let stopFollowing: (() => void) | undefined; loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { if (cancelled || !containerRef.current) return; const first = valid[0]?.point; @@ -44,15 +46,18 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true, zooms: [3, 20] }); + mapInstance = map; map.addControl(new AMap.Scale()); if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '148px' } })); - map.on?.('dragstart', () => followChangeRef.current(false)); + stopFollowing = () => followChangeRef.current(false); + map.on?.('dragstart', stopFollowing); mapRef.current = map; amapRef.current = AMap; setState('ready'); }).catch(() => { if (!cancelled) setState('error'); }); return () => { cancelled = true; + if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); currentMarkerRef.current?.setMap?.(null); mapRef.current?.destroy(); 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 697e3f7c..a853dc2c 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 @@ -18,6 +18,7 @@ const vehicles = [{ }] as VehicleRealtimeRow[]; const monitorMap = { clusters: [], points: [], total: 2 }; const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); +const vehicleCardArgsSpy = vi.hoisted(() => vi.fn()); const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false })); vi.mock('../map/FleetMap', () => ({ @@ -48,7 +49,10 @@ vi.mock('../hooks/useMonitorData', () => ({ map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData }, selectedVehicle: { data: { items: [] } } }), - useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} }) + useMonitorVehicleCard: (...args: unknown[]) => { + vehicleCardArgsSpy(...args); + return { detail: {}, activeAlerts: {}, address: {} }; + } })); afterEach(() => { @@ -56,6 +60,8 @@ afterEach(() => { monitorQueryFlags.isLoading = false; monitorQueryFlags.isFetching = false; monitorQueryFlags.isPlaceholderData = false; + vehicleCardArgsSpy.mockClear(); + vi.restoreAllMocks(); }); test('starts without a selection and supports expand, collapse, reselection, and clear', () => { @@ -78,13 +84,16 @@ test('starts without a selection and supports expand, collapse, reselection, and expect(firstVehicle).toHaveClass('is-selected'); expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument(); expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001'); + expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true); const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length; + const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' })); expect(workspace).toHaveClass('is-detail-collapsed'); expect(firstVehicle).toHaveClass('is-selected'); expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument(); expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection); + expect(vehicleCardArgsSpy).toHaveBeenCalledTimes(cardCallsAfterSelection); fireEvent.click(secondVehicle); expect(workspace).toHaveClass('is-detail-open'); @@ -111,13 +120,13 @@ test('switches to a lightweight realtime list and resolves addresses only on dem fireEvent.click(screen.getByRole('button', { name: /列表/ })); expect(await screen.findByText('车辆实时列表')).toBeInTheDocument(); expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument(); - expect(await screen.findAllByText('粤A12345')).toHaveLength(2); - expect(screen.getAllByText('42')).toHaveLength(2); - expect(screen.getAllByText(/1,234/)).toHaveLength(2); - expect(screen.getAllByText(/113\.260000/)).toHaveLength(2); + expect(await screen.findAllByText('粤A12345')).toHaveLength(1); + expect(screen.getAllByText('42')).toHaveLength(1); + expect(screen.getAllByText(/1,234/)).toHaveLength(1); + expect(screen.getAllByText(/113\.260000/)).toHaveLength(1); expect(reverseGeocode).not.toHaveBeenCalled(); - fireEvent.click(screen.getAllByRole('button', { name: '解析粤A12345位置' })[0]); + fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' })); expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument(); expect(reverseGeocode).toHaveBeenCalledTimes(1); expect(screen.queryByText('综合状态')).not.toBeInTheDocument(); @@ -125,6 +134,33 @@ test('switches to a lightweight realtime list and resolves addresses only on dem expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument(); }); +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); + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render(); + + fireEvent.click(screen.getByRole('button', { name: /列表/ })); + expect(await screen.findByText('车辆实时列表')).toBeInTheDocument(); + await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards article')).toHaveLength(1)); + expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument(); + expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + + view.unmount(); + expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); +}); + 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 e9191056..6e92e9c2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -4,15 +4,29 @@ import QRCode from 'qrcode'; import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../../api/client'; -import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types'; +import type { VehicleRealtimeRow } from '../../api/types'; import { FleetMap } from '../map/FleetMap'; import { EmptyState, InlineError } from '../shared/AsyncState'; import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor'; import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData'; +import { QUERY_MEMORY } from '../queryPolicy'; const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT']; const statuses = ['', 'online', 'offline', 'driving', 'idle']; const EMPTY_VEHICLES: VehicleRealtimeRow[] = []; +const MOBILE_MONITOR_QUERY = '(max-width: 760px)'; + +function useMobileMonitorLayout() { + const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_MONITOR_QUERY).matches); + useEffect(() => { + const media = window.matchMedia(MOBILE_MONITOR_QUERY); + const update = () => setMobile(media.matches); + media.addEventListener('change', update); + update(); + return () => media.removeEventListener('change', update); + }, []); + return mobile; +} type AddressCoordinate = { longitude: number; latitude: number; key: string }; @@ -50,21 +64,22 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic }); function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) { + const mobile = useMobileMonitorLayout(); return
车辆实时列表速度、里程和坐标实时刷新,文字地址按需解析
{total.toLocaleString('zh-CN')} 辆车辆
-
+ {!mobile ?
车辆速度总里程经纬度地理位置
{rows.map((row) => - )}
车辆速度总里程经纬度地理位置
{formatNumber(row.speedKmh, 1)}km/h {formatNumber(row.totalMileageKm, 1)}km {row.longitude.toFixed(6)}
{row.latitude.toFixed(6)}
{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
-
{rows.map((row) =>
+ )}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
: null} + {mobile ?
{rows.map((row) =>
{row.plate || '未绑定车牌'}{row.vin}
{formatNumber(row.speedKmh, 1)}km/h
总里程
{formatNumber(row.totalMileageKm, 1)} km
经纬度
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}
地理位置
-
)}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
+ )}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null} : null}
第 {page} / {totalPages} 页
; } @@ -95,19 +110,17 @@ const MemoFleetMap = memo(FleetMap); function VehicleDetailCard({ vehicle, - detail, - activeAlerts, - address, onCollapse, onClear }: { vehicle: VehicleRealtimeRow; - detail?: VehicleDetailData; - activeAlerts?: { items: AlertEvent[]; total: number }; - address?: MapReverseGeocode; onCollapse: () => void; onClear: () => void; }) { + const card = useMonitorVehicleCard(vehicle.vin, vehicle, true); + const detail = card.detail.data; + const activeAlerts = card.activeAlerts.data; + const address = card.address.data; const status = vehicleStatus(vehicle); const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm; const latestAlert = activeAlerts?.items[0]; @@ -188,7 +201,8 @@ export default function MonitorPage() { const realtimeListQuery = useQuery({ queryKey: ['monitor', 'vehicle-list', listParams.toString()], queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal), - enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false + enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false, + gcTime: QUERY_MEMORY.highVolumeGcTime }); const rows = useMemo(() => { const data = vehicles.data?.items ?? []; @@ -234,7 +248,6 @@ export default function MonitorPage() { const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]); const collapseDetail = useCallback(() => setDetailOpen(false), []); const expandDetail = useCallback(() => setDetailOpen(true), []); - const card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin)); const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length; const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length; const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length; @@ -307,9 +320,6 @@ export default function MonitorPage() { {selected && detailOpen ? ( diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index d21e8130..1a20bceb 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -2,6 +2,17 @@ This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. +## 2026-07-16: monitor lifecycle and responsive rendering + +The monitor's high-volume fleet, map and selected-vehicle queries now use immediate inactive-cache collection instead of inheriting the global five-minute retention. The detail card owns its detail, alert and address queries, so collapsing or clearing the card unmounts its observers while the separate selected-vehicle stream continues to move the map. Repeated viewport and vehicle-selection tests assert that only the active payload remains and that detail-card queries reach zero after unmount. + +The realtime list no longer renders the desktop table and mobile cards simultaneously. A viewport listener mounts exactly one representation, cutting a 50-row page from 100 row trees and 100 address query observers to 50. The listener is removed with the component. Fleet and track maps also keep stable event-handler identities and pair every AMap `on` with `off` before overlay removal and `destroy()`. + +This follows TanStack Query's documented behavior that inactive queries remain cached for five minutes unless `gcTime` changes, and AMap JS API 2.0's requirement to unbind the same handler object with `off` that was registered with `on`: + +- +- + ## 2026-07-16: production stylesheet boundary The production entry renders `AppV2` exclusively. V2 pages do not import Semi UI components, so loading the legacy `global.css` from `main.tsx` made every route download and parse the complete legacy Semi theme and thousands of unreachable V1 rules. The entry now imports only `v2/styles/v2.css`; the legacy stylesheet remains available to the explicit `test:legacy` compatibility suite but cannot enter the V2 production bundle. `productionEntry.test.ts` protects this boundary. The verified production CSS fell from 435.20 kB / 53.50 kB gzip to 168.91 kB / 27.72 kB gzip, and the Semi theme Sass deprecation warnings disappeared from the build.