From 25f3fa9c6422edd8f149065ab8e08d6626242ebd Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 04:52:35 +0800 Subject: [PATCH] fix(monitor): isolate batch search scope --- .../v2/hooks/useMonitorData.cache.test.tsx | 39 +++++++++++++++++++ .../web/src/v2/hooks/useMonitorData.test.ts | 11 +++++- .../apps/web/src/v2/hooks/useMonitorData.ts | 15 +++++-- .../web/src/v2/pages/MonitorPage.test.tsx | 20 +++++++++- .../apps/web/src/v2/pages/MonitorPage.tsx | 26 +++++++------ .../apps/web/src/v2/queryPolicy.test.ts | 4 ++ .../apps/web/src/v2/queryPolicy.ts | 4 +- .../docs/frontend-production-readiness.md | 14 +++++++ 8 files changed, 113 insertions(+), 20 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx index 83613ca4..bf33a9ac 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx @@ -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 }) => {children}; + 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 }); diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts index d79f5c14..1bb9ccc4 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts @@ -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', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts index 695365a9..354dd701 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -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(query: UseQueryResult, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult { return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult; } @@ -65,13 +71,14 @@ function projectWorkspaceQuery(query: UseQueryResult({ + queryKey: ['monitor', 'workspace', filterScope, mapParams.toString()], queryFn: ({ signal }) => api.monitorWorkspace(mapParams, signal), enabled: mapEnabled, - placeholderData: (previous) => previous, + placeholderData: retainPreviousPageWithinScope(filterScope, 2), staleTime: 5_000, gcTime: MONITOR_CACHE.mapGcTime, refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 3eef4e4e..463ec289 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -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(); @@ -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(); + + 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(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 0b90d849..4ca37c63 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -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>({ + 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>(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(); 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() { 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() { ) : null} - : setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />} + : setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
实时数据状态 diff --git a/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts b/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts index 2c9fcfb8..c76cf46b 100644 --- a/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts @@ -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('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', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts b/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts index 1cad0dc5..16308370 100644 --- a/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts +++ b/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts @@ -4,8 +4,8 @@ export const QUERY_MEMORY = { summaryGcTime: 60_000 } as const; -export function retainPreviousPageWithinScope(scope: string) { - return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[1] === scope ? previous : undefined; +export function retainPreviousPageWithinScope(scope: string, scopeIndex = 1) { + return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[scopeIndex] === scope ? previous : undefined; } export function queryScopeKey(query: object) { diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 8377c46a..22fc7e41 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -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: + +- +- +- + +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.