perf(web): release monitor resources on unmount
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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 }) => <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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<NonNullable<AMapMap['getBounds']>> => ({}));
|
||||
const mapHandlers = new Map<string, (event: unknown) => 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(<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 () => {
|
||||
const [west, south] = wgs84ToGcj02(113, 22);
|
||||
const [east, north] = wgs84ToGcj02(114, 24);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(<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 () => {
|
||||
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
@@ -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 <section className="v2-monitor-table-panel">
|
||||
<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}>
|
||||
<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.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><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>
|
||||
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
|
||||
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
</section>;
|
||||
}
|
||||
@@ -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 ? (
|
||||
<VehicleDetailCard
|
||||
vehicle={selected}
|
||||
detail={card.detail.data}
|
||||
activeAlerts={card.activeAlerts.data}
|
||||
address={card.address.data}
|
||||
onCollapse={collapseDetail}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
@@ -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`:
|
||||
|
||||
- <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
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user