fix(monitor): isolate batch search scope

This commit is contained in:
lingniu
2026-07-16 04:52:35 +08:00
parent 14525887ea
commit 25f3fa9c64
8 changed files with 113 additions and 20 deletions

View File

@@ -41,6 +41,45 @@ test('rapid viewport changes retain one workspace payload and avoid duplicate fl
client.clear();
});
test('retains workspace data for viewport changes but clears it across filter scopes', async () => {
const oldWorkspace = {
summary: { totalVehicles: 1, onlineVehicles: 1, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 0, unknownVehicles: 0, alertVehicles: 0, activeToday: 1, frameToday: 1, alertDataAvailable: false, truncated: false, asOf: '' },
vehicles: { items: [{ vin: 'OLDVIN', plate: '旧车牌' }], total: 1, limit: 200, offset: 0 },
map: { mode: 'points', zoom: 13, total: 1, truncated: false, points: [], clusters: [], asOf: '' }
} as never;
let calls = 0;
const monitorWorkspace = vi.spyOn(api, 'monitorWorkspace').mockImplementation(() => {
calls += 1;
return calls === 1 ? Promise.resolve(oldWorkspace) : new Promise(() => undefined);
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
const view = renderHook(({ bounds, protocol }) => useMonitorData(
{ keyword: '', protocol, status: '' },
{ zoom: 13, bounds },
'',
true,
false
), {
wrapper,
initialProps: { bounds: '113,22,114,23', protocol: '' }
});
await waitFor(() => expect(view.result.current.vehicles.data?.items[0]?.vin).toBe('OLDVIN'));
view.rerender({ bounds: '113.1,22,114.1,23', protocol: '' });
await waitFor(() => expect(monitorWorkspace).toHaveBeenCalledTimes(2));
expect(view.result.current.vehicles.data?.items[0]?.vin).toBe('OLDVIN');
expect(view.result.current.vehicles.isPlaceholderData).toBe(true);
view.rerender({ bounds: '113.1,22,114.1,23', protocol: 'JT808' });
await waitFor(() => expect(monitorWorkspace).toHaveBeenCalledTimes(3));
expect(view.result.current.vehicles.data).toBeUndefined();
expect(view.result.current.vehicles.isLoading).toBe(true);
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 });

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_CACHE, MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_CACHE, MONITOR_REFRESH, monitorFilterScope, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData';
describe('monitor query params', () => {
it('keeps server-owned status filters and bounded list size', () => {
@@ -28,6 +28,15 @@ describe('monitor query params', () => {
expect(params.get('keyword')).toBeNull();
expect(parseMonitorSearchTerms(Array.from({ length: MAX_MONITOR_SEARCH_TERMS + 5 }, (_, index) => `粤A${index}`).join('\n'))).toHaveLength(MAX_MONITOR_SEARCH_TERMS);
});
it('builds filter scope independently from viewport and result limit', () => {
const scope = new URLSearchParams(monitorFilterScope({ keyword: '粤a12345\n粤B67890', protocol: 'JT808', status: 'online' }));
expect(scope.get('keywords')).toBe('粤A12345,粤B67890');
expect(scope.get('protocol')).toBe('JT808');
expect(scope.get('status')).toBe('online');
expect(scope.get('online')).toBe('online');
expect(scope.has('limit')).toBe(false);
});
});
describe('monitor refresh cadence', () => {

View File

@@ -1,7 +1,7 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types';
import { QUERY_MEMORY } from '../queryPolicy';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
export type MonitorFilters = {
keyword: string;
@@ -58,6 +58,12 @@ export function monitorMapQueryParams(filters: MonitorFilters, viewport: Monitor
return params;
}
export function monitorFilterScope(filters: MonitorFilters) {
const params = monitorQueryParams(filters, 0);
params.delete('limit');
return params.toString();
}
function projectWorkspaceQuery<T>(query: UseQueryResult<MonitorWorkspaceResponse>, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult<T> {
return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult<T>;
}
@@ -65,13 +71,14 @@ function projectWorkspaceQuery<T>(query: UseQueryResult<MonitorWorkspaceResponse
export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string, mapEnabled = true, vehicleEnabled = true) {
const params = monitorQueryParams(filters, 200);
const mapParams = monitorMapQueryParams(filters, viewport);
const filterScope = monitorFilterScope(filters);
mapParams.set('railLimit', '200');
const workspace = useQuery({
queryKey: ['monitor', 'workspace', mapParams.toString()],
const workspace = useQuery<MonitorWorkspaceResponse>({
queryKey: ['monitor', 'workspace', filterScope, mapParams.toString()],
queryFn: ({ signal }) => api.monitorWorkspace(mapParams, signal),
enabled: mapEnabled,
placeholderData: (previous) => previous,
placeholderData: retainPreviousPageWithinScope<MonitorWorkspaceResponse>(filterScope, 2),
staleTime: 5_000,
gcTime: MONITOR_CACHE.mapGcTime,
refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false

View File

@@ -43,6 +43,13 @@ vi.mock('../hooks/useMonitorData', () => ({
if (filters.status) params.set('status', filters.status);
return params;
},
monitorFilterScope: (filters: { keyword: string; protocol: string; status: string }) => {
const params = new URLSearchParams();
if (filters.keyword) params.set('keyword', filters.keyword);
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
return params.toString();
},
useMonitorData: () => ({
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
@@ -204,7 +211,8 @@ test('opens an explicit batch editor and applies copied plate rows', async () =>
});
});
test('hides stale fleet rows while a pasted batch is still loading', () => {
test('hides stale fleet rows while a pasted batch scope is still loading', () => {
monitorQueryFlags.isLoading = true;
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
@@ -215,6 +223,16 @@ test('hides stale fleet rows while a pasted batch is still loading', () => {
expect(screen.queryByRole('button', { name: /粤A12345 LTEST000000000001/ })).not.toBeInTheDocument();
});
test('keeps same-filter fleet rows visible while a viewport placeholder refreshes', () => {
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ })).toBeInTheDocument();
expect(screen.queryByText('加载车辆')).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toBeInTheDocument();
});
test('reports pasted plates that have no exact realtime match', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);

View File

@@ -4,12 +4,12 @@ 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 { VehicleRealtimeRow } from '../../api/types';
import type { Page, 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';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
@@ -213,16 +213,17 @@ export default function MonitorPage() {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []);
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
const listScope = useMemo(() => monitorFilterScope(filters), [filters]);
const listParams = useMemo(() => {
const params = monitorQueryParams(filters, listLimit);
params.set('offset', String(listOffset));
return params;
}, [filters, listLimit, listOffset]);
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map');
const realtimeListQuery = useQuery({
queryKey: ['monitor', 'vehicle-list', listParams.toString()],
const realtimeListQuery = useQuery<Page<VehicleRealtimeRow>>({
queryKey: ['monitor', 'vehicle-list', listScope, listLimit, listOffset],
queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal),
enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false,
enabled: mode === 'list', placeholderData: retainPreviousPageWithinScope<Page<VehicleRealtimeRow>>(listScope, 2), refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const rows = useMemo(() => {
@@ -233,9 +234,8 @@ export default function MonitorPage() {
const listRows = realtimeListQuery.data?.items ?? EMPTY_VEHICLES;
const activeBatchRows = mode === 'map' ? rows : listRows;
const activeBatchQuery = mode === 'map' ? vehicles : realtimeListQuery;
const batchSearchPending = searchTerms.length > 1 && (
keyword !== deferredKeyword || activeBatchQuery.isLoading || activeBatchQuery.isPlaceholderData
);
const filterTransitionPending = keyword !== deferredKeyword || activeBatchQuery.isLoading;
const batchSearchPending = searchTerms.length > 1 && filterTransitionPending;
const batchMatch = useMemo(() => {
const identities = new Set<string>();
for (const vehicle of activeBatchRows) {
@@ -245,7 +245,7 @@ export default function MonitorPage() {
const missing = searchTerms.filter((term) => !identities.has(term));
return { matched: searchTerms.length - missing.length, missing };
}, [activeBatchRows, searchTerms]);
const visibleRows = batchSearchPending ? [] : rows;
const visibleRows = filterTransitionPending ? [] : rows;
const batchStatus = batchSearchPending
? `正在查找 ${searchTerms.length}`
: batchMatch.missing.length
@@ -254,6 +254,8 @@ export default function MonitorPage() {
const batchStatusTitle = batchMatch.missing.length
? `未找到:${batchMatch.missing.join('、')}`
: searchTerms.join('、');
const visibleListRows = filterTransitionPending ? EMPTY_VEHICLES : listRows;
const visibleListTotal = filterTransitionPending ? 0 : (realtimeListQuery.data?.total ?? 0);
const selected = selectedVin
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined;
@@ -334,7 +336,7 @@ export default function MonitorPage() {
</div>
<MemoFleetMap
vehicles={visibleRows}
monitorMap={searchTerms.length > 1 && (batchSearchPending || map.isPlaceholderData) ? undefined : map.data}
monitorMap={filterTransitionPending ? undefined : map.data}
selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle}
onSelectVin={selectVehicle}
@@ -356,7 +358,7 @@ export default function MonitorPage() {
</button>
</aside>
) : null}
</section> : <MonitorVehicleTable rows={realtimeListQuery.data?.items ?? []} total={realtimeListQuery.data?.total ?? 0} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil((realtimeListQuery.data?.total ?? 0) / listLimit))} limit={listLimit} loading={realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<section className="v2-event-strip">
<strong></strong>

View File

@@ -15,6 +15,10 @@ describe('query memory policy', () => {
expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=A&date=2026-07-16', 50, 50] })).toBe(previous);
expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=B&date=2026-07-16', 50, 0] })).toBeUndefined();
expect(placeholder(previous)).toBeUndefined();
const nestedPlaceholder = retainPreviousPageWithinScope<typeof previous>('vehicle=A&date=2026-07-16', 2);
expect(nestedPlaceholder(previous, { queryKey: ['monitor', 'workspace', 'vehicle=A&date=2026-07-16', 'viewport-b'] })).toBe(previous);
expect(nestedPlaceholder(previous, { queryKey: ['monitor', 'workspace', 'vehicle=B&date=2026-07-16', 'viewport-b'] })).toBeUndefined();
});
it('builds a stable scope key independent of object order and empty values', () => {

View File

@@ -4,8 +4,8 @@ export const QUERY_MEMORY = {
summaryGcTime: 60_000
} as const;
export function retainPreviousPageWithinScope<T>(scope: string) {
return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[1] === scope ? previous : undefined;
export function retainPreviousPageWithinScope<T>(scope: string, scopeIndex = 1) {
return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[scopeIndex] === scope ? previous : undefined;
}
export function queryScopeKey(query: object) {