fix(monitor): isolate batch search scope
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user