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 7b8d3fe2..956d58b1 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -105,6 +105,34 @@ test('high-volume history, track, and mileage requests forward AbortSignal', asy for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal }); }); +test('route-scoped platform reads forward AbortSignal on navigation cleanup', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response); + const controller = new AbortController(); + const signal = controller.signal; + + await api.session(signal); + await api.metricCatalog(signal); + await api.historyMetricCatalog(signal); + await api.historyExports(signal); + await api.accessSummary({}, signal); + await api.accessVehicles({ limit: 50, offset: 0 }, signal); + await api.accessUnresolvedIdentities({ limit: 20, offset: 0 }, signal); + await api.accessThresholds(signal); + await api.alertSummaryV2({}, signal); + await api.alertEventsV2({ limit: 20, offset: 0 }, signal); + await api.alertEventV2('event-1', signal); + await api.alertRulesV2(signal); + await api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal); + await api.vehicleDetail(new URLSearchParams({ keyword: 'VIN001' }), signal); + await api.latestTelemetry('VIN001', signal); + await api.reverseGeocode(new URLSearchParams({ longitude: '113', latitude: '23' }), signal); + await api.opsHealth(signal); + await api.sourceReadiness(signal); + + expect(fetchMock).toHaveBeenCalledTimes(18); + for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBe(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 929ae277..63eb8f7e 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -101,6 +101,10 @@ function withAuthorization(headers: HeadersInit | undefined, token: string) { return authorized; } +function withSignal(init: RequestInit | undefined, signal?: AbortSignal) { + return signal ? { ...init, signal } : init; +} + async function responseErrorMessage(response: Response) { try { const envelope = (await response.json()) as ApiErrorEnvelope; @@ -124,49 +128,49 @@ function withTraceID(message: string, traceID?: string) { } export const api = { - session: () => request('/api/v2/session'), + session: (signal?: AbortSignal) => request('/api/v2/session', withSignal(undefined, signal)), 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(), signal?: AbortSignal) => request(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined), - metricCatalog: () => request('/api/v2/metrics'), - historyMetricCatalog: () => request('/api/v2/history/metrics'), + metricCatalog: (signal?: AbortSignal) => request('/api/v2/metrics', withSignal(undefined, signal)), + historyMetricCatalog: (signal?: AbortSignal) => request('/api/v2/history/metrics', withSignal(undefined, signal)), historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined), historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined), - historyExports: () => request('/api/v2/exports'), + historyExports: (signal?: AbortSignal) => request('/api/v2/exports', withSignal(undefined, signal)), createHistoryExport: (query: HistoryExportRequest) => request('/api/v2/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }), - accessSummary: (query: AccessQuery) => request('/api/v2/access/summary', { + accessSummary: (query: AccessQuery, signal?: AbortSignal) => request('/api/v2/access/summary', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }), - accessVehicles: (query: AccessQuery) => request>('/api/v2/access/vehicles', { + }, signal)), + accessVehicles: (query: AccessQuery, signal?: AbortSignal) => request>('/api/v2/access/vehicles', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }), - accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery) => request>('/api/v2/access/unresolved-identities', { + }, signal)), + accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery, signal?: AbortSignal) => request>('/api/v2/access/unresolved-identities', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }), - accessThresholds: () => request('/api/v2/access/thresholds'), + }, signal)), + accessThresholds: (signal?: AbortSignal) => request('/api/v2/access/thresholds', withSignal(undefined, signal)), updateAccessThresholds: (update: AccessThresholdUpdate) => request('/api/v2/access/thresholds', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update) }), - alertSummaryV2: (query: AlertQuery) => request('/api/v2/alerts/summary', { + alertSummaryV2: (query: AlertQuery, signal?: AbortSignal) => request('/api/v2/alerts/summary', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }), - alertEventsV2: (query: AlertQuery) => request>('/api/v2/alerts/events', { + }, signal)), + alertEventsV2: (query: AlertQuery, signal?: AbortSignal) => request>('/api/v2/alerts/events', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }), - alertEventV2: (id: string) => request(`/api/v2/alerts/events/${encodeURIComponent(id)}`), + }, signal)), + alertEventV2: (id: string, signal?: AbortSignal) => request(`/api/v2/alerts/events/${encodeURIComponent(id)}`, withSignal(undefined, signal)), actOnAlertV2: (id: string, action: Pick & { version: number; actor?: string }) => request(`/api/v2/alerts/events/${encodeURIComponent(id)}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action) }), - alertRulesV2: () => request('/api/v2/alerts/rules'), + alertRulesV2: (signal?: AbortSignal) => request('/api/v2/alerts/rules', withSignal(undefined, signal)), saveAlertRuleV2: (input: AlertRuleInput) => request(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', { method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }), setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update) }), - alertNotificationsV2: (params = new URLSearchParams()) => request>(`/api/v2/alerts/notifications?${params.toString()}`), + alertNotificationsV2: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/v2/alerts/notifications?${params.toString()}`, withSignal(undefined, signal)), readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }), @@ -175,9 +179,9 @@ export const api = { vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined), vehicleCoverageSummary: (params = new URLSearchParams()) => request(`/api/vehicles/coverage/summary?${params.toString()}`), - vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicle-service?${params.toString()}`), + vehicleDetail: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)), vehicleProfile: (vin: string) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`), - latestTelemetry: (vin: string) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`), + latestTelemetry: (vin: string, signal?: AbortSignal) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, withSignal(undefined, signal)), updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }), @@ -211,7 +215,7 @@ export const api = { alertEventSummary: (params = new URLSearchParams()) => request(`/api/alert-events/summary?${params.toString()}`), alertEvents: (params = new URLSearchParams()) => request>(`/api/alert-events?${params.toString()}`), alertEventNotificationPlan: (params = new URLSearchParams()) => request(`/api/alert-events/notification-plan?${params.toString()}`), - reverseGeocode: (params = new URLSearchParams()) => request(`/api/map/reverse-geocode?${params.toString()}`), - opsHealth: () => request('/api/ops/health'), - sourceReadiness: () => request('/api/ops/source-readiness') + reverseGeocode: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/map/reverse-geocode?${params.toString()}`, withSignal(undefined, signal)), + opsHealth: (signal?: AbortSignal) => request('/api/ops/health', withSignal(undefined, signal)), + sourceReadiness: (signal?: AbortSignal) => request('/api/ops/source-readiness', withSignal(undefined, signal)) }; diff --git a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx index c52f81f9..eb022dcc 100644 --- a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx +++ b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx @@ -4,6 +4,7 @@ import { AppShell } from './layout/AppShell'; import { AuthGate } from './auth/AuthGate'; import { RoutePage } from './routing/RouteBoundary'; import { RoutePages } from './routing/routeModules'; +import { ROUTER_FUTURE } from './routing/routerConfig'; const queryClient = new QueryClient({ defaultOptions: { @@ -19,7 +20,7 @@ const queryClient = new QueryClient({ export function AppV2() { return ( - + }> } /> diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx index dca79e38..9b1625d9 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx @@ -22,7 +22,7 @@ export function AuthGate({ children }: { children: ReactNode }) { const [attempted, setAttempted] = useState(() => Boolean(getAccessToken())); const session = useQuery({ queryKey: ['platform-session', tokenVersion], - queryFn: api.session, + queryFn: ({ signal }) => api.session(signal), retry: false, staleTime: Infinity }); 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 b7fdfa80..a71fbdaa 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -104,23 +104,23 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, const detail = useQuery({ queryKey: ['monitor', 'vehicle-card', 'detail', vin], - queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin })), + queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal), enabled, staleTime: 30_000 }); const activeAlerts = useQuery({ queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin], - queryFn: () => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }), + queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal), enabled, staleTime: 10_000, refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false }); const address = useQuery({ queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude], - queryFn: () => api.reverseGeocode(new URLSearchParams({ + queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: longitude!.toFixed(6), latitude: latitude!.toFixed(6) - })), + }), signal), enabled: enabled && hasCoordinate, staleTime: 60 * 60_000 }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 6c1a7483..d63fd3f1 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -8,6 +8,7 @@ import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThresho import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; +import { QUERY_MEMORY } from '../queryPolicy'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; @@ -99,10 +100,10 @@ export default function AccessPage() { const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState(''); const [thresholdDraft, setThresholdDraft] = useState(); const queryClient = useQueryClient(); const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]); - const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: () => api.accessSummary({}), staleTime: 15_000 }); - const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous }); - const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 15_000 }); - const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 }); + const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); + const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); + const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); + const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime }); useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]); const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } }); const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx new file mode 100644 index 00000000..84e22caa --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -0,0 +1,37 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import AlertsPage from './AlertsPage'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; + +const mocks = vi.hoisted(() => ({ + alertSummaryV2: vi.fn(), alertEventsV2: vi.fn(), alertEventV2: vi.fn(), actOnAlertV2: vi.fn(), + alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(), + saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn() +})); +vi.mock('../../api/client', () => ({ api: mocks })); +vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) })); + +afterEach(() => { + cleanup(); + Object.values(mocks).forEach((mock) => mock.mockReset()); +}); + +test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => { + mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); + mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); + mocks.alertRulesV2.mockResolvedValue([]); + mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' }); + mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称'); + fireEvent.change(keyword, { target: { value: '保留筛选草稿' } }); + fireEvent.click(screen.getByRole('button', { name: /站内通知/ })); + expect(await screen.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /告警事件/ })); + + expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿'); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 03d2780e..8e3f52e0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -8,6 +8,7 @@ import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, rule import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister, canOperate } from '../auth/session'; +import { QUERY_MEMORY } from '../queryPolicy'; type Tab = 'events' | 'rules' | 'notifications'; type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string }; @@ -38,15 +39,15 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on ; } -function EventWorkspace({ filters, setFilters, rules, unread, editable, onTab }: { filters: Filters; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) { - const [draft, setDraft] = useState(filters); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient(); +function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) { + const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient(); const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]); const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]); - const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: () => api.alertSummaryV2(baseQuery), staleTime: 8_000 }); - const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: () => api.alertEventsV2(query), placeholderData: (previous) => previous, staleTime: 5_000 }); + const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: ({ signal }) => api.alertSummaryV2(baseQuery, signal), staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime }); + const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: (previous) => previous, staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); const rows = events.data?.items ?? []; useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]); - const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: () => api.alertEventV2(selectedID), enabled: Boolean(selectedID), staleTime: 3_000 }); + const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime }); const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } }); const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); }; const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); }; @@ -109,7 +110,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri } function NotificationsWorkspace({ editable }: { editable: boolean }) { - const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ limit: '100' })), staleTime: 5_000 }); + const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime }); const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } }); return
站内通知仅站内通道具备真实送达与已读状态
{editable ? : 只读}
{notifications.isError ? notifications.refetch()} /> : null}
{notifications.data?.items.map((item) =>
{item.title}

{item.content}

{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}
{editable && !item.read ? : null}
)}
外部通知通道短信(SMS)— 预留 / 未启用邮件(Email)— 预留 / 未启用企业微信(WeCom)— 预留 / 未启用
; } @@ -118,9 +119,9 @@ export default function AlertsPage() { const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session); const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events'); const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' }; - const [filters, setFilterState] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: api.alertRulesV2, staleTime: 30_000 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: api.metricCatalog, staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' })), staleTime: 5_000 }); + const [filters, setFilterState] = useState(initialFilters); const [eventDraft, setEventDraft] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: ({ signal }) => api.alertRulesV2(signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime }); const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); }; const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); }; const activeTab = tab === 'rules' && !admin ? 'events' : tab; - return

告警中心

统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。

{rules.isError ? rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? metrics.refetch()} /> : null}{activeTab === 'events' ? : activeTab === 'rules' ? : }
; + return

告警中心

统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。

{rules.isError ? rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? metrics.refetch()} /> : null}{activeTab === 'events' ? : activeTab === 'rules' ? : }
; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index a18c37e7..433304f2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -42,7 +42,7 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque } function ExportJobsPanel() { - const query = useQuery({ queryKey: ['history-exports'], queryFn: api.historyExports, refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false }); + const query = useQuery({ queryKey: ['history-exports'], queryFn: ({ signal }) => api.historyExports(signal), refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false }); const jobs = query.data ?? []; return
导出任务{jobs.length}
{jobs.slice(0, 6).map((job) =>
{job.name}{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}
{job.downloadUrl ? 下载 : {job.status === 'running' ? `${job.progress}%` : '—'}}
)}{query.isError ?
导出任务加载失败
: !jobs.length ?
尚未创建导出任务
: null}
; } @@ -79,7 +79,7 @@ export default function HistoryPage() { if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria, keywords]); - const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 }); + const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: ({ signal }) => api.historyMetricCatalog(signal), staleTime: 30 * 60_000 }); const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: ({ signal }) => api.historyData(params, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); const result = dataQuery.data; 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 46ce1831..697e3f7c 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 @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { VehicleRealtimeRow } from '../../api/types'; import { api } from '../../api/client'; import MonitorPage from './MonitorPage'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; const vehicles = [{ vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808', @@ -59,7 +60,7 @@ afterEach(() => { test('starts without a selection and supports expand, collapse, reselection, and clear', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const view = render(); + const view = render(); const workspace = view.container.querySelector('.v2-monitor-workspace')!; const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }); const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }); @@ -106,7 +107,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 }); const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /列表/ })); expect(await screen.findByText('车辆实时列表')).toBeInTheDocument(); expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument(); @@ -127,7 +128,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem test('pastes, deduplicates, and submits multiple plates as one batch search', async () => { const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + render(); const input = screen.getByRole('textbox', { name: '搜索车辆' }); fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } }); @@ -145,7 +146,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as test('hides stale fleet rows while a pasted batch is still loading', () => { monitorQueryFlags.isPlaceholderData = true; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + render(); fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } }); expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument(); @@ -155,7 +156,7 @@ test('hides stale fleet rows while a pasted batch is still loading', () => { test('reports pasted plates that have no exact realtime match', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + render(); fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } }); expect(screen.getByText('已找到 1/2')).toBeInTheDocument(); 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 52c7f0bd..e9191056 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -30,10 +30,10 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic const [requested, setRequested] = useState(); const addressQuery = useQuery({ queryKey: ['monitor', 'list-address', requested?.key], - queryFn: () => api.reverseGeocode(new URLSearchParams({ + queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: requested!.longitude.toFixed(4), latitude: requested!.latitude.toFixed(4) - })), + }), signal), enabled: Boolean(requested), staleTime: 6 * 60 * 60_000, gcTime: 24 * 60 * 60_000, diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index 75d86752..50637180 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -8,8 +8,8 @@ function statusLabel(status: string) { } export default function OperationsPage() { - const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: api.opsHealth, refetchInterval: 15_000, staleTime: 8_000 }); - const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: api.sourceReadiness, refetchInterval: 30_000, staleTime: 15_000 }); + const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000 }); + const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000 }); const data = health.data; const sources = readiness.data; const refresh = () => Promise.all([health.refetch(), readiness.refetch()]); return
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx index 9b68cd9f..6fc85dc1 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import StatisticsPage from './StatisticsPage'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() })); const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() })); @@ -13,7 +14,7 @@ afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).f function renderPage(initialEntry = '/statistics') { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(); + return render(); } function prepareData() { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index 8c0be355..93377b02 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import type { TrackPlaybackResponse } from '../../api/types'; import TrackPage from './TrackPage'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() })); vi.mock('../../api/client', () => ({ api: mocks })); @@ -39,7 +40,7 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const view = render(); + const view = render(); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index e0edde38..242e534e 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -208,7 +208,7 @@ export default function TrackPage() { const addressQuery = useQuery({ queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)], enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000, - queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) })) + queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }), signal) }); useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index 352f594a..b5bc8c01 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -9,6 +9,7 @@ import { api } from '../../api/client'; import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; +import { QUERY_MEMORY } from '../queryPolicy'; import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry'; import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync'; import { FleetMap } from '../map/FleetMap'; @@ -195,8 +196,8 @@ function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, on export default function VehiclePage() { const { vin } = useParams(); - const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' })) }); - const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: () => api.latestTelemetry(vin!), staleTime: 10_000, refetchInterval: 20_000, refetchIntervalInBackground: false }); + const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime }); + const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, refetchIntervalInBackground: false }); if (!vin) return ; if (query.isPending) return ; if (query.isError) return
query.refetch()} />
; diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx index 0f6a2be6..40deb3b5 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx @@ -1,7 +1,9 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { MemoryRouter, useSearchParams } from 'react-router-dom'; import { isRouteChunkError, RoutePage } from './RouteBoundary'; +import { ROUTER_FUTURE } from './routerConfig'; afterEach(() => { cleanup(); @@ -19,7 +21,7 @@ describe('route recovery boundary', () => { it('isolates a page render failure and keeps a visible recovery action', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); const BrokenPage = () => { throw new Error('render exploded'); }; - render(); + render(); expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示'); expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor'); @@ -29,8 +31,23 @@ describe('route recovery boundary', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/statistics', at: Date.now() })); const StaleChunkPage = () => { throw new TypeError('Failed to fetch dynamically imported module: /assets/Statistics-old.js'); }; - render(); + render(); expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新'); expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument(); }); + + it('keeps page state mounted when only URL search parameters change', () => { + let mounts = 0; + function FilterablePage() { + const [count, setCount] = useState(0); + const [, setSearchParams] = useSearchParams(); + useEffect(() => { mounts += 1; }, []); + return <>本地状态 {count}; + } + render(); + fireEvent.click(screen.getByRole('button', { name: '修改本地状态' })); + fireEvent.click(screen.getByRole('button', { name: '修改查询参数' })); + expect(screen.getByText('本地状态 1')).toBeInTheDocument(); + expect(mounts).toBe(1); + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx index e27185b8..289c02a6 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx @@ -65,7 +65,7 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey export function RoutePage({ page: Page, label }: { page: ElementType; label: string }) { const location = useLocation(); const routeKey = `${location.pathname}${location.search}`; - return + return }> ; } diff --git a/vehicle-data-platform/apps/web/src/v2/routing/routerConfig.ts b/vehicle-data-platform/apps/web/src/v2/routing/routerConfig.ts new file mode 100644 index 00000000..b21b08df --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/routing/routerConfig.ts @@ -0,0 +1,4 @@ +export const ROUTER_FUTURE = { + v7_startTransition: true, + v7_relativeSplatPath: true +} as const; diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index d9d1fb43..bbe6b6c9 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -34,6 +34,11 @@ The web application splits every major route into a hashed lazy-loaded asset. An CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web +# Archives created on macOS must disable AppleDouble metadata. As a server-side +# guard, remove any metadata files before recording the release manifest. +COPYFILE_DISABLE=1 tar -C apps/web/dist -czf "/tmp/$PLATFORM_RELEASE-web.tar.gz" . +find "$NEW_WEB" -type f -name '._*' -delete + # Record only this release's own build assets before adding compatibility files. find "$NEW_WEB/assets" -type f -printf '%P\n' | sort > "$NEW_WEB/.release-assets" diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 8ea0f03f..bede70c7 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -10,6 +10,8 @@ This document records verified risks, the production controls that address them, | --- | --- | --- | --- | | A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell | | A pasted batch search temporarily shows the previous fleet | Operators may mistake stale vehicles for batch matches | Hide placeholder rows while `keywords` is changing; report matched and missing plates after the request resolves | Paste newline/comma-separated plates with a duplicate and an unknown plate; verify exact results and missing-count feedback | +| URL filters remount the whole route | Pagination or tab changes lose local state and may flash a loading screen | Key the route recovery boundary by pathname only; keep the full URL only for chunk-reload diagnostics | Change search parameters inside a stateful route and assert one mount with preserved local state | +| Route-scoped reads continue after navigation | Obsolete responses consume bandwidth and remain in memory | Consume TanStack Query's `AbortSignal` in every V2 read path; immediately collect high-volume access and alert pages after they become inactive | Start a read, navigate away or change its query key, and assert the fetch receives the query signal | | A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop | | Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes | | Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |