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) {
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
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: scope-safe monitor batch search
|
||||
|
||||
The monitor already accepts copied plate rows from Excel or text, normalizes separators and duplicates, and submits one bounded batch parameter. Its workspace placeholder, however, previously ignored the semantic filter boundary: changing plates, protocol or status could momentarily render the preceding vehicles under the new controls. Conversely, treating every workspace key change as a new scope would blank the map during ordinary pan and zoom.
|
||||
|
||||
Monitor queries now separate the stable filter scope from viewport and pagination. Plate/protocol/status changes immediately clear the old rail, map payload and list rows while the replacement query loads. Pan and zoom inside the same filter scope retain the preceding map payload until the new viewport data arrives, and list pagination retains its previous page without carrying rows into another filter. Batch pending state follows the actual filter request rather than TanStack Query's same-scope placeholder flag.
|
||||
|
||||
This applies TanStack Query's previous-data pagination pattern only within one semantic result scope and matches dashboard systems where query and filter controls define the visible dataset. Low refresh intervals remain bounded because mature dashboard guidance warns that aggressive polling increases backend load:
|
||||
|
||||
- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
|
||||
- <https://www.elastic.co/docs/explore-analyze/dashboards/using>
|
||||
- <https://grafana.com/docs/grafana/latest/dashboards/troubleshoot-dashboards/>
|
||||
|
||||
Regression tests cover copied multi-plate parsing, scope normalization, nested query-key scope matching, stale-row suppression during a batch transition and same-filter viewport continuity.
|
||||
|
||||
## 2026-07-16: scope-safe operational tables
|
||||
|
||||
Access Management and Alert Center repeated the history-page placeholder mistake. A new vehicle/protocol/status filter continued rendering the preceding page until the replacement request completed. Alert Center also kept its previous event selection alive, so the inspector could show an old event ID, vehicle and action evidence beside a newly submitted filter.
|
||||
|
||||
Reference in New Issue
Block a user