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

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