diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index afd9fed3..50480681 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -84,6 +84,13 @@ test('trackPlayback preserves the bounded V2 query contract', async () => { expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', undefined); }); +test('monitor viewport requests forward AbortSignal so obsolete pans can be cancelled', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { points: [], clusters: [] } }) } as Response); + const controller = new AbortController(); + await api.monitorMap(new URLSearchParams({ zoom: '13', bounds: '113,22,114,23' }), controller.signal); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal }); +}); + test('rawFramesQuery posts structured JSON instead of URL query strings', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index fd7192c9..95404fef 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -125,8 +125,8 @@ function withTraceID(message: string, traceID?: string) { export const api = { session: () => request('/api/v2/session'), - monitorSummary: (params = new URLSearchParams()) => request(`/api/v2/monitor/summary?${params.toString()}`), - monitorMap: (params = new URLSearchParams()) => request(`/api/v2/monitor/map?${params.toString()}`), + monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), + monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), trackPlayback: (params = new URLSearchParams()) => request(`/api/v2/tracks?${params.toString()}`), metricCatalog: () => request('/api/v2/metrics'), historyMetricCatalog: () => request('/api/v2/history/metrics'), @@ -191,7 +191,7 @@ export const api = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }), - vehicleRealtime: (params = new URLSearchParams()) => request>(`/api/realtime/vehicles?${params.toString()}`), + vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined), realtimeLocations: (params = new URLSearchParams()) => request>(`/api/realtime/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request>(`/api/history/locations?${params.toString()}`), rawFrames: (params = new URLSearchParams()) => request>(`/api/history/raw-frames?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 22ff6a65..3a6e7ded 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -192,7 +192,7 @@ export function Mileage({ const summaryItems = [ { label: '区间总里程', value: `${formatKm(statistics?.periodMileageKm)} km`, note: '按车辆和自然日归并' }, - { label: '统计车辆', value: statistics ? `${statistics.vehicleCount.toLocaleString()} 辆` : '—', note: `${statistics?.recordCount ?? 0} 个有效车辆日` }, + { label: '统计车辆', value: statistics ? `${(statistics.vehicleCount ?? 0).toLocaleString()} 辆` : '—', note: `${statistics?.recordCount ?? 0} 个有效车辆日` }, { label: '单车平均', value: `${formatKm(statistics?.averageMileagePerVin)} km`, note: '区间总里程 / 车辆数' }, { label: '车日均里程', value: `${formatKm(statistics?.averageDailyMileageKm)} km`, note: '有效车辆日平均' } ]; @@ -262,7 +262,7 @@ export function Mileage({ title={(
每日里程趋势{filters.dateFrom} 至 {filters.dateTo}
- {statistics?.trend.length ?? 0} 个有效自然日 + {statistics?.trend?.length ?? 0} 个有效自然日
)} > diff --git a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx index c24c3c43..c52f81f9 100644 --- a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx +++ b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx @@ -1,18 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { lazy, Suspense } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppShell } from './layout/AppShell'; import { AuthGate } from './auth/AuthGate'; -import { PageLoading } from './shared/AsyncState'; - -const MonitorPage = lazy(() => import('./pages/MonitorPage')); -const VehiclePage = lazy(() => import('./pages/VehiclePage')); -const TrackPage = lazy(() => import('./pages/TrackPage')); -const HistoryPage = lazy(() => import('./pages/HistoryPage')); -const StatisticsPage = lazy(() => import('./pages/StatisticsPage')); -const AccessPage = lazy(() => import('./pages/AccessPage')); -const AlertsPage = lazy(() => import('./pages/AlertsPage')); -const OperationsPage = lazy(() => import('./pages/OperationsPage')); +import { RoutePage } from './routing/RouteBoundary'; +import { RoutePages } from './routing/routeModules'; const queryClient = new QueryClient({ defaultOptions: { @@ -32,14 +23,14 @@ export function AppV2() { }> } /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> 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 new file mode 100644 index 00000000..ff2463ff --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { api } from '../../api/client'; +import { useMonitorData } from './useMonitorData'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test('rapid viewport changes retain at most one monitor map payload', 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 monitorMap = vi.spyOn(api, 'monitorMap').mockResolvedValue({ mode: 'points', zoom: 13, total: 0, truncated: false, points: [], clusters: [], asOf: '' }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + const filters = { keyword: '', protocol: '', status: '' }; + const view = renderHook(({ bounds }) => useMonitorData(filters, { zoom: 13, bounds }, '', true, false), { + wrapper, + initialProps: { bounds: '113.0000,22.0000,114.0000,23.0000' } + }); + + await waitFor(() => expect(monitorMap).toHaveBeenCalledTimes(1)); + for (let index = 1; index <= 8; index += 1) { + view.rerender({ bounds: `${113 + index / 1000},22,${114 + index / 1000},23` }); + await waitFor(() => expect(monitorMap.mock.calls.length).toBeGreaterThanOrEqual(index + 1)); + } + await waitFor(() => { + const cachedMaps = client.getQueryCache().findAll({ queryKey: ['monitor', 'map'] }); + expect(cachedMaps).toHaveLength(1); + }); + view.unmount(); + client.clear(); +}); 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 67db1171..d79f5c14 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_REFRESH, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData'; +import { MAX_MONITOR_SEARCH_TERMS, MONITOR_CACHE, MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData'; describe('monitor query params', () => { it('keeps server-owned status filters and bounded list size', () => { @@ -36,4 +36,8 @@ describe('monitor refresh cadence', () => { expect(MONITOR_REFRESH.selected).toBeLessThan(MONITOR_REFRESH.fleet); expect(MONITOR_REFRESH.fleet).toBeLessThan(MONITOR_REFRESH.summary); }); + + it('does not retain inactive viewport payloads in the query cache', () => { + expect(MONITOR_CACHE.mapGcTime).toBe(0); + }); }); 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 c85ba58c..b7fdfa80 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -20,6 +20,8 @@ export const MONITOR_REFRESH = { alerts: 15_000 } as const; +export const MONITOR_CACHE = { mapGcTime: 0 } as const; + export const MAX_MONITOR_SEARCH_TERMS = 100; export function parseMonitorSearchTerms(value: string) { @@ -61,12 +63,12 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor const summary = useQuery({ queryKey: ['monitor', 'summary', params.toString()], - queryFn: () => api.monitorSummary(params), + queryFn: ({ signal }) => api.monitorSummary(params, signal), refetchInterval: MONITOR_REFRESH.summary }); const vehicles = useQuery({ queryKey: ['monitor', 'vehicles', params.toString()], - queryFn: () => api.vehicleRealtime(params), + queryFn: ({ signal }) => api.vehicleRealtime(params, signal), enabled: vehicleEnabled, placeholderData: (previous) => previous, refetchInterval: MONITOR_REFRESH.fleet @@ -74,16 +76,17 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor const map = useQuery({ queryKey: ['monitor', 'map', mapParams.toString()], - queryFn: () => api.monitorMap(mapParams), + queryFn: ({ signal }) => api.monitorMap(mapParams, signal), enabled: mapEnabled, placeholderData: (previous) => previous, staleTime: 5_000, + gcTime: MONITOR_CACHE.mapGcTime, refetchInterval: MONITOR_REFRESH.fleet }); const selectedVehicle = useQuery({ queryKey: ['monitor', 'selected-vehicle', selectedVin], - queryFn: () => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' })), + queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' }), signal), enabled: Boolean(selectedVin), staleTime: 5_000, refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index 9edfdc70..d561759c 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -14,6 +14,7 @@ import { import { useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { usePlatformSession } from '../auth/AuthGate'; +import { preloadRoute } from '../routing/routeModules'; const navigation = [ { to: '/monitor', label: '全局监控', icon: IconHome }, @@ -62,6 +63,7 @@ export function AppShell() { function Sidebar() { const [collapsed, setCollapsed] = useState(false); + const warmRoute = (path: string) => { void preloadRoute(path); }; return (