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;
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 = {

View File

@@ -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();
});

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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 } } });

View File

@@ -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}
/>