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;
|
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 = {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user