perf(web): release monitor resources on unmount

This commit is contained in:
lingniu
2026-07-16 00:59:57 +08:00
parent c00f50551a
commit e80e1dcea9
9 changed files with 194 additions and 37 deletions

View File

@@ -8,6 +8,7 @@ export type AMapMap = {
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void; setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
destroy: () => void; destroy: () => void;
on?: (eventName: string, handler: (event: unknown) => void) => void; on?: (eventName: string, handler: (event: unknown) => void) => void;
off?: (eventName: string, handler: (event: unknown) => void) => void;
getZoom?: () => number; getZoom?: () => number;
getBounds?: () => { getBounds?: () => {
getSouthWest?: () => { getLng?: () => number; getLat?: () => number }; getSouthWest?: () => { getLng?: () => number; getLat?: () => number };
@@ -22,6 +23,7 @@ export type AMapMap = {
export type AMapOverlay = { export type AMapOverlay = {
setMap?: (map: AMapMap | null) => void; setMap?: (map: AMapMap | null) => void;
on?: (eventName: string, handler: () => void) => void; on?: (eventName: string, handler: () => void) => void;
off?: (eventName: string, handler: () => void) => void;
setPosition?: (position: [number, number]) => void; setPosition?: (position: [number, number]) => void;
setPath?: (path: [number, number][]) => void; setPath?: (path: [number, number][]) => void;
}; };
@@ -31,6 +33,7 @@ export type AMapMassMarks = {
setData: (data: AMapMassPoint[]) => void; setData: (data: AMapMassPoint[]) => void;
setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void; setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void;
on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void; on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
off?: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
}; };
export type AMapLabelsLayer = { export type AMapLabelsLayer = {

View File

@@ -1,9 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { afterEach, expect, test, vi } from 'vitest';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { useMonitorData } from './useMonitorData'; import { useMonitorData, useMonitorVehicleCard } from './useMonitorData';
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -33,3 +33,50 @@ test('rapid viewport changes retain at most one monitor map payload', async () =
view.unmount(); view.unmount();
client.clear(); 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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
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(<QueryClientProvider client={client}>{null}</QueryClientProvider>);
expect(detail).not.toHaveBeenCalled();
expect(alerts).not.toHaveBeenCalled();
expect(address).not.toHaveBeenCalled();
view.rerender(<QueryClientProvider client={client}><ActiveCard /></QueryClientProvider>);
await waitFor(() => expect(detail).toHaveBeenCalledTimes(1));
await waitFor(() => expect(alerts).toHaveBeenCalledTimes(1));
await waitFor(() => expect(address).toHaveBeenCalledTimes(1));
view.rerender(<QueryClientProvider client={client}>{null}</QueryClientProvider>);
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['monitor', 'vehicle-card'] })).toHaveLength(0));
view.unmount();
client.clear();
});

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { VehicleRealtimeRow } from '../../api/types'; import type { VehicleRealtimeRow } from '../../api/types';
import { QUERY_MEMORY } from '../queryPolicy';
export type MonitorFilters = { export type MonitorFilters = {
keyword: string; keyword: string;
@@ -64,14 +65,16 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
const summary = useQuery({ const summary = useQuery({
queryKey: ['monitor', 'summary', params.toString()], queryKey: ['monitor', 'summary', params.toString()],
queryFn: ({ signal }) => api.monitorSummary(params, signal), queryFn: ({ signal }) => api.monitorSummary(params, signal),
refetchInterval: MONITOR_REFRESH.summary refetchInterval: MONITOR_REFRESH.summary,
gcTime: QUERY_MEMORY.summaryGcTime
}); });
const vehicles = useQuery({ const vehicles = useQuery({
queryKey: ['monitor', 'vehicles', params.toString()], queryKey: ['monitor', 'vehicles', params.toString()],
queryFn: ({ signal }) => api.vehicleRealtime(params, signal), queryFn: ({ signal }) => api.vehicleRealtime(params, signal),
enabled: vehicleEnabled, enabled: vehicleEnabled,
placeholderData: (previous) => previous, placeholderData: (previous) => previous,
refetchInterval: MONITOR_REFRESH.fleet refetchInterval: MONITOR_REFRESH.fleet,
gcTime: QUERY_MEMORY.highVolumeGcTime
}); });
const map = useQuery({ 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), queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' }), signal),
enabled: Boolean(selectedVin), enabled: Boolean(selectedVin),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
}); });
return { summary, vehicles, map, selectedVehicle }; return { summary, vehicles, map, selectedVehicle };
@@ -106,14 +110,16 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
queryKey: ['monitor', 'vehicle-card', 'detail', vin], queryKey: ['monitor', 'vehicle-card', 'detail', vin],
queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal),
enabled, enabled,
staleTime: 30_000 staleTime: 30_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
}); });
const activeAlerts = useQuery({ const activeAlerts = useQuery({
queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin], queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin],
queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal), queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal),
enabled, enabled,
staleTime: 10_000, staleTime: 10_000,
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
}); });
const address = useQuery({ const address = useQuery({
queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude], queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude],
@@ -122,7 +128,8 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
latitude: latitude!.toFixed(6) latitude: latitude!.toFixed(6)
}), signal), }), signal),
enabled: enabled && hasCoordinate, enabled: enabled && hasCoordinate,
staleTime: 60 * 60_000 staleTime: 60 * 60_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
}); });
return { detail, activeAlerts, address }; return { detail, activeAlerts, address };

View File

@@ -13,6 +13,9 @@ const markerSetMap = vi.fn();
const markerSetPosition = vi.fn(); const markerSetPosition = vi.fn();
const setZoomAndCenter = vi.fn(); const setZoomAndCenter = vi.fn();
const panTo = vi.fn(); const panTo = vi.fn();
const mapOff = vi.fn();
const mapDestroy = vi.fn();
const massOff = vi.fn();
const getZoom = vi.fn(() => 5); const getZoom = vi.fn(() => 5);
const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({})); const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({}));
const mapHandlers = new Map<string, (event: unknown) => void>(); const mapHandlers = new Map<string, (event: unknown) => void>();
@@ -26,8 +29,9 @@ class TestMap {
} }
add = vi.fn(); add = vi.fn();
addControl = vi.fn(); addControl = vi.fn();
destroy = vi.fn(); destroy = mapDestroy;
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler)); on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
off = mapOff;
getZoom = getZoom; getZoom = getZoom;
getBounds = getBounds; getBounds = getBounds;
setZoomAndCenter = setZoomAndCenter; setZoomAndCenter = setZoomAndCenter;
@@ -36,6 +40,7 @@ class TestMap {
class TestMassMarks { class TestMassMarks {
on = vi.fn(); on = vi.fn();
off = massOff;
setMap = vi.fn(); setMap = vi.fn();
setData = setData; setData = setData;
setStyle = setStyle; setStyle = setStyle;
@@ -145,6 +150,9 @@ afterEach(() => {
markerSetPosition.mockReset(); markerSetPosition.mockReset();
setZoomAndCenter.mockReset(); setZoomAndCenter.mockReset();
panTo.mockReset(); panTo.mockReset();
mapOff.mockReset();
mapDestroy.mockReset();
massOff.mockReset();
getZoom.mockReset(); getZoom.mockReset();
getZoom.mockReturnValue(5); getZoom.mockReturnValue(5);
getBounds.mockReset(); 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+'); 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(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => 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 () => { test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22); const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24); const [east, north] = wgs84ToGcj02(114, 24);

View File

@@ -171,6 +171,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
let cancelled = false; let cancelled = false;
let resizeTimer: number | undefined; let resizeTimer: number | undefined;
let resizeObserver: ResizeObserver | 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'); setState('loading');
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
@@ -186,13 +191,15 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
showLabel: true, showLabel: true,
resizeEnable: true resizeEnable: true
}); });
mapInstance = map;
map.addControl(new AMap.Scale()); map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } })); if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } }));
const styles = [ const styles = [
...COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) })) ...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] }); 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); const cluster = clustersRef.current.get(event.data.id);
if (cluster) { if (cluster) {
map.setZoomAndCenter?.(Math.min(20, (map.getZoom?.() ?? 5) + 2), wgs84ToGcj02(cluster.longitude, cluster.latitude)); 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); const vehicle = vehiclesRef.current.get(event.data.id);
if (vehicle) onSelectRef.current(vehicle); if (vehicle) onSelectRef.current(vehicle);
}); };
mass.on('click', selectMassPoint);
mass.setMap(map); mass.setMap(map);
const labels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [11, 18.99], zIndex: 110, collision: true, allowCollision: false }) : null; 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; const denseLabels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }) : null;
labels?.setMap(map); labels?.setMap(map);
const notifyViewport = () => { notifyViewport = () => {
setMapZoom(map.getZoom?.() ?? 5); setMapZoom(map.getZoom?.() ?? 5);
window.clearTimeout(viewportTimerRef.current); window.clearTimeout(viewportTimerRef.current);
viewportTimerRef.current = window.setTimeout(() => { viewportTimerRef.current = window.setTimeout(() => {
@@ -219,11 +227,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
}; };
map.on?.('moveend', notifyViewport); map.on?.('moveend', notifyViewport);
map.on?.('zoomend', notifyViewport); map.on?.('zoomend', notifyViewport);
map.on?.('dragstart', () => { stopFollowing = () => {
if (!centeredVinRef.current) return; if (!centeredVinRef.current) return;
followSelectedRef.current = false; followSelectedRef.current = false;
setFollowSelected(false); setFollowSelected(false);
}); };
map.on?.('dragstart', stopFollowing);
mapRef.current = map; mapRef.current = map;
amapRef.current = AMap; amapRef.current = AMap;
massRef.current = mass; massRef.current = mass;
@@ -246,6 +255,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
resizeObserver?.disconnect(); resizeObserver?.disconnect();
window.clearTimeout(resizeTimer); window.clearTimeout(resizeTimer);
window.clearTimeout(viewportTimerRef.current); 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); massRef.current?.setMap(null);
labelsRef.current?.setMap(null); labelsRef.current?.setMap(null);
denseLabelsRef.current?.setMap(null); denseLabelsRef.current?.setMap(null);

View File

@@ -35,6 +35,8 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
useEffect(() => { useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; } if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
let cancelled = false; let cancelled = false;
let mapInstance: AMapMap | undefined;
let stopFollowing: (() => void) | undefined;
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
const first = valid[0]?.point; 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, viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true,
zooms: [3, 20] zooms: [3, 20]
}); });
mapInstance = map;
map.addControl(new AMap.Scale()); map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '148px' } })); 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; mapRef.current = map;
amapRef.current = AMap; amapRef.current = AMap;
setState('ready'); setState('ready');
}).catch(() => { if (!cancelled) setState('error'); }); }).catch(() => { if (!cancelled) setState('error'); });
return () => { return () => {
cancelled = true; cancelled = true;
if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing);
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null); currentMarkerRef.current?.setMap?.(null);
mapRef.current?.destroy(); mapRef.current?.destroy();

View File

@@ -18,6 +18,7 @@ const vehicles = [{
}] as VehicleRealtimeRow[]; }] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 }; const monitorMap = { clusters: [], points: [], total: 2 };
const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false })); const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
vi.mock('../map/FleetMap', () => ({ vi.mock('../map/FleetMap', () => ({
@@ -48,7 +49,10 @@ vi.mock('../hooks/useMonitorData', () => ({
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData }, map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } } selectedVehicle: { data: { items: [] } }
}), }),
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} }) useMonitorVehicleCard: (...args: unknown[]) => {
vehicleCardArgsSpy(...args);
return { detail: {}, activeAlerts: {}, address: {} };
}
})); }));
afterEach(() => { afterEach(() => {
@@ -56,6 +60,8 @@ afterEach(() => {
monitorQueryFlags.isLoading = false; monitorQueryFlags.isLoading = false;
monitorQueryFlags.isFetching = false; monitorQueryFlags.isFetching = false;
monitorQueryFlags.isPlaceholderData = false; monitorQueryFlags.isPlaceholderData = false;
vehicleCardArgsSpy.mockClear();
vi.restoreAllMocks();
}); });
test('starts without a selection and supports expand, collapse, reselection, and clear', () => { 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(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001'); expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length; const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' })); fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' }));
expect(workspace).toHaveClass('is-detail-collapsed'); expect(workspace).toHaveClass('is-detail-collapsed');
expect(firstVehicle).toHaveClass('is-selected'); expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection); expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
expect(vehicleCardArgsSpy).toHaveBeenCalledTimes(cardCallsAfterSelection);
fireEvent.click(secondVehicle); fireEvent.click(secondVehicle);
expect(workspace).toHaveClass('is-detail-open'); 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: /列表/ })); fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument(); expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument(); expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(2); expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(2); expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(2); expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getAllByText(/113\.260000/)).toHaveLength(2); expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(reverseGeocode).not.toHaveBeenCalled(); 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(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
expect(reverseGeocode).toHaveBeenCalledTimes(1); expect(reverseGeocode).toHaveBeenCalledTimes(1);
expect(screen.queryByText('综合状态')).not.toBeInTheDocument(); 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(); 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(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
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 () => { 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 vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@@ -4,15 +4,29 @@ import QRCode from 'qrcode';
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../api/client'; 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 { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState'; import { EmptyState, InlineError } from '../shared/AsyncState';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor'; 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 { 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 protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle']; const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = []; 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 }; 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 }) { 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 <section className="v2-monitor-table-panel"> return <section className="v2-monitor-table-panel">
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header> <header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
<div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead> {!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}> <tbody>{rows.map((row) => <tr key={row.vin}>
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td> <td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td> <td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td> <td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td> <td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
<td><MonitorAddressCell vehicle={row} /></td> <td><MonitorAddressCell vehicle={row} /></td>
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> </tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}> {mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header> <header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
<dl><div><dt></dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt></dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl> <dl><div><dt></dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt></dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin /></button></footer> <footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin /></button></footer>
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> </article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
<footer><span> {page} / {totalPages} </span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}></button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}></button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer> <footer><span> {page} / {totalPages} </span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}></button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}></button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer>
</section>; </section>;
} }
@@ -95,19 +110,17 @@ const MemoFleetMap = memo(FleetMap);
function VehicleDetailCard({ function VehicleDetailCard({
vehicle, vehicle,
detail,
activeAlerts,
address,
onCollapse, onCollapse,
onClear onClear
}: { }: {
vehicle: VehicleRealtimeRow; vehicle: VehicleRealtimeRow;
detail?: VehicleDetailData;
activeAlerts?: { items: AlertEvent[]; total: number };
address?: MapReverseGeocode;
onCollapse: () => void; onCollapse: () => void;
onClear: () => 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 status = vehicleStatus(vehicle);
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm; const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
const latestAlert = activeAlerts?.items[0]; const latestAlert = activeAlerts?.items[0];
@@ -188,7 +201,8 @@ export default function MonitorPage() {
const realtimeListQuery = useQuery({ const realtimeListQuery = useQuery({
queryKey: ['monitor', 'vehicle-list', listParams.toString()], queryKey: ['monitor', 'vehicle-list', listParams.toString()],
queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal), 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 rows = useMemo(() => {
const data = vehicles.data?.items ?? []; const data = vehicles.data?.items ?? [];
@@ -234,7 +248,6 @@ export default function MonitorPage() {
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]); const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => setDetailOpen(false), []); const collapseDetail = useCallback(() => setDetailOpen(false), []);
const expandDetail = useCallback(() => setDetailOpen(true), []); const expandDetail = useCallback(() => setDetailOpen(true), []);
const card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin));
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length; const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length; const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length; const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
@@ -307,9 +320,6 @@ export default function MonitorPage() {
{selected && detailOpen ? ( {selected && detailOpen ? (
<VehicleDetailCard <VehicleDetailCard
vehicle={selected} vehicle={selected}
detail={card.detail.data}
activeAlerts={card.activeAlerts.data}
address={card.address.data}
onCollapse={collapseDetail} onCollapse={collapseDetail}
onClear={clearSelection} onClear={clearSelection}
/> />

View File

@@ -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. 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`:
- <https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults>
- <https://lbs.amap.com/api/javascript-api-v2/guide/events/map_overlay>
## 2026-07-16: production stylesheet boundary ## 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. 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.