fix(web): harden route transitions and monitor memory

This commit is contained in:
lingniu
2026-07-15 23:54:21 +08:00
parent 3fabcf181a
commit c29ccdf2da
17 changed files with 332 additions and 36 deletions

View File

@@ -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); 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 () => { test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, ok: true,

View File

@@ -125,8 +125,8 @@ function withTraceID(message: string, traceID?: string) {
export const api = { export const api = {
session: () => request<SessionInfo>('/api/v2/session'), session: () => request<SessionInfo>('/api/v2/session'),
monitorSummary: (params = new URLSearchParams()) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
monitorMap: (params = new URLSearchParams()) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`), trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`),
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'), metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'), historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
@@ -191,7 +191,7 @@ export const api = {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query) body: JSON.stringify(query)
}), }),
vehicleRealtime: (params = new URLSearchParams()) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`), vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`), realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`), rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),

View File

@@ -192,7 +192,7 @@ export function Mileage({
const summaryItems = [ const summaryItems = [
{ label: '区间总里程', value: `${formatKm(statistics?.periodMileageKm)} km`, note: '按车辆和自然日归并' }, { 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?.averageMileagePerVin)} km`, note: '区间总里程 / 车辆数' },
{ label: '车日均里程', value: `${formatKm(statistics?.averageDailyMileageKm)} km`, note: '有效车辆日平均' } { label: '车日均里程', value: `${formatKm(statistics?.averageDailyMileageKm)} km`, note: '有效车辆日平均' }
]; ];
@@ -262,7 +262,7 @@ export function Mileage({
title={( title={(
<div className="vp-mileage-report-card-title"> <div className="vp-mileage-report-card-title">
<div><strong></strong><span>{filters.dateFrom} {filters.dateTo}</span></div> <div><strong></strong><span>{filters.dateFrom} {filters.dateTo}</span></div>
<Tag color="blue">{statistics?.trend.length ?? 0} </Tag> <Tag color="blue">{statistics?.trend?.length ?? 0} </Tag>
</div> </div>
)} )}
> >

View File

@@ -1,18 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell'; import { AppShell } from './layout/AppShell';
import { AuthGate } from './auth/AuthGate'; import { AuthGate } from './auth/AuthGate';
import { PageLoading } from './shared/AsyncState'; import { RoutePage } from './routing/RouteBoundary';
import { RoutePages } from './routing/routeModules';
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'));
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -32,14 +23,14 @@ export function AppV2() {
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} /> <Route index element={<Navigate to="/monitor" replace />} />
<Route path="/monitor" element={<Suspense fallback={<PageLoading />}><MonitorPage /></Suspense>} /> <Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} label="全局监控" />} />
<Route path="/vehicles/:vin?" element={<Suspense fallback={<PageLoading />}><VehiclePage /></Suspense>} /> <Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} label="车辆查询" />} />
<Route path="/tracks" element={<Suspense fallback={<PageLoading />}><TrackPage /></Suspense>} /> <Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} label="轨迹回放" />} />
<Route path="/history" element={<Suspense fallback={<PageLoading />}><HistoryPage /></Suspense>} /> <Route path="/history" element={<RoutePage page={RoutePages.History} label="历史数据" />} />
<Route path="/statistics" element={<Suspense fallback={<PageLoading />}><StatisticsPage /></Suspense>} /> <Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} label="里程查询" />} />
<Route path="/access" element={<Suspense fallback={<PageLoading />}><AccessPage /></Suspense>} /> <Route path="/access" element={<RoutePage page={RoutePages.Access} label="接入管理" />} />
<Route path="/alerts/*" element={<Suspense fallback={<PageLoading />}><AlertsPage /></Suspense>} /> <Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} label="告警中心" />} />
<Route path="/operations" element={<Suspense fallback={<PageLoading />}><OperationsPage /></Suspense>} /> <Route path="/operations" element={<RoutePage page={RoutePages.Operations} label="运维质量" />} />
<Route path="*" element={<Navigate to="/monitor" replace />} /> <Route path="*" element={<Navigate to="/monitor" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
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();
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('monitor query params', () => {
it('keeps server-owned status filters and bounded list size', () => { 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.selected).toBeLessThan(MONITOR_REFRESH.fleet);
expect(MONITOR_REFRESH.fleet).toBeLessThan(MONITOR_REFRESH.summary); 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);
});
}); });

View File

@@ -20,6 +20,8 @@ export const MONITOR_REFRESH = {
alerts: 15_000 alerts: 15_000
} as const; } as const;
export const MONITOR_CACHE = { mapGcTime: 0 } as const;
export const MAX_MONITOR_SEARCH_TERMS = 100; export const MAX_MONITOR_SEARCH_TERMS = 100;
export function parseMonitorSearchTerms(value: string) { export function parseMonitorSearchTerms(value: string) {
@@ -61,12 +63,12 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
const summary = useQuery({ const summary = useQuery({
queryKey: ['monitor', 'summary', params.toString()], queryKey: ['monitor', 'summary', params.toString()],
queryFn: () => api.monitorSummary(params), queryFn: ({ signal }) => api.monitorSummary(params, signal),
refetchInterval: MONITOR_REFRESH.summary refetchInterval: MONITOR_REFRESH.summary
}); });
const vehicles = useQuery({ const vehicles = useQuery({
queryKey: ['monitor', 'vehicles', params.toString()], queryKey: ['monitor', 'vehicles', params.toString()],
queryFn: () => api.vehicleRealtime(params), queryFn: ({ signal }) => api.vehicleRealtime(params, signal),
enabled: vehicleEnabled, enabled: vehicleEnabled,
placeholderData: (previous) => previous, placeholderData: (previous) => previous,
refetchInterval: MONITOR_REFRESH.fleet refetchInterval: MONITOR_REFRESH.fleet
@@ -74,16 +76,17 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
const map = useQuery({ const map = useQuery({
queryKey: ['monitor', 'map', mapParams.toString()], queryKey: ['monitor', 'map', mapParams.toString()],
queryFn: () => api.monitorMap(mapParams), queryFn: ({ signal }) => api.monitorMap(mapParams, signal),
enabled: mapEnabled, enabled: mapEnabled,
placeholderData: (previous) => previous, placeholderData: (previous) => previous,
staleTime: 5_000, staleTime: 5_000,
gcTime: MONITOR_CACHE.mapGcTime,
refetchInterval: MONITOR_REFRESH.fleet refetchInterval: MONITOR_REFRESH.fleet
}); });
const selectedVehicle = useQuery({ const selectedVehicle = useQuery({
queryKey: ['monitor', 'selected-vehicle', selectedVin], 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), enabled: Boolean(selectedVin),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false

View File

@@ -14,6 +14,7 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
import { preloadRoute } from '../routing/routeModules';
const navigation = [ const navigation = [
{ to: '/monitor', label: '全局监控', icon: IconHome }, { to: '/monitor', label: '全局监控', icon: IconHome },
@@ -62,6 +63,7 @@ export function AppShell() {
function Sidebar() { function Sidebar() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const warmRoute = (path: string) => { void preloadRoute(path); };
return ( return (
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}> <aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
@@ -71,13 +73,13 @@ function Sidebar() {
</div> </div>
<nav className="v2-navigation" aria-label="主导航"> <nav className="v2-navigation" aria-label="主导航">
{navigation.map(({ to, label, icon: Icon }) => ( {navigation.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}> <NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
<Icon size="large" /> <Icon size="large" />
<span className="v2-nav-label">{label}</span> <span className="v2-nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<NavLink to="/operations" className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}> <NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
<IconSetting size="large" /> <IconSetting size="large" />
<span className="v2-nav-label"></span> <span className="v2-nav-label"></span>
</NavLink> </NavLink>

View File

@@ -71,7 +71,11 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, on
function MobileEntry({ onClose }: { onClose: () => void }) { function MobileEntry({ onClose }: { onClose: () => void }) {
const [qr, setQr] = useState(''); const [qr, setQr] = useState('');
const url = `${window.location.origin}/monitor`; const url = `${window.location.origin}/monitor`;
useEffect(() => { void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then(setQr); }, [url]); useEffect(() => {
let cancelled = false;
void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then((value) => { if (!cancelled) setQr(value); });
return () => { cancelled = true; };
}, [url]);
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3></h3><p>使 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>访</button></section></div>; return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3></h3><p>使 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>访</button></section></div>;
} }
@@ -182,7 +186,7 @@ export default function MonitorPage() {
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map'); const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map');
const realtimeListQuery = useQuery({ const realtimeListQuery = useQuery({
queryKey: ['monitor', 'vehicle-list', listParams.toString()], queryKey: ['monitor', 'vehicle-list', listParams.toString()],
queryFn: () => api.vehicleRealtime(listParams), queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal),
enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false
}); });
const rows = useMemo(() => { const rows = useMemo(() => {

View File

@@ -0,0 +1,36 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { isRouteChunkError, RoutePage } from './RouteBoundary';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe('route recovery boundary', () => {
it('recognizes deployment-related lazy chunk failures', () => {
expect(isRouteChunkError(new TypeError('Failed to fetch dynamically imported module: /assets/Monitor-old.js'))).toBe(true);
expect(isRouteChunkError(new Error('Loading chunk 18 failed'))).toBe(true);
expect(isRouteChunkError(new Error('Cannot read properties of undefined'))).toBe(false);
});
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(<MemoryRouter initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
});
it('shows a deployment recovery page when an automatic chunk reload already ran', () => {
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(<MemoryRouter initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新');
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
import { IconAlertTriangle, IconHome, IconRefresh } from '@douyinfe/semi-icons';
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense } from 'react';
import { useLocation } from 'react-router-dom';
import { PageLoading } from '../shared/AsyncState';
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
const CHUNK_RELOAD_COOLDOWN_MS = 2 * 60_000;
export function isRouteChunkError(error: unknown) {
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
return /ChunkLoadError|Loading chunk .+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
}
function canAutoReload(routeKey: string) {
try {
const marker = JSON.parse(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) ?? 'null') as { routeKey?: string; at?: number } | null;
return !marker || marker.routeKey !== routeKey || Date.now() - (marker.at ?? 0) > CHUNK_RELOAD_COOLDOWN_MS;
} catch {
return true;
}
}
function rememberAutoReload(routeKey: string) {
try {
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, JSON.stringify({ routeKey, at: Date.now() }));
} catch {
// Recovery must still work when storage is unavailable.
}
}
class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey: string }, { error?: Error }> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, _info: ErrorInfo) {
if (isRouteChunkError(error) && canAutoReload(this.props.routeKey)) {
rememberAutoReload(this.props.routeKey);
window.location.reload();
}
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
const chunkError = isRouteChunkError(error);
return <section className="v2-route-error" role="alert">
<span><IconAlertTriangle /></span>
<div>
<small>{chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
<h2>{chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
<p>{chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。可刷新当前页面重试。'}</p>
<code>{error.message || error.name}</code>
<footer>
<button type="button" onClick={() => window.location.reload()}><IconRefresh /></button>
<a href="/monitor"><IconHome /></a>
</footer>
</div>
</section>;
}
}
export function RoutePage({ page: Page, label }: { page: ElementType; label: string }) {
const location = useLocation();
const routeKey = `${location.pathname}${location.search}`;
return <RecoverableRouteBoundary key={routeKey} routeKey={routeKey}>
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page /></Suspense>
</RecoverableRouteBoundary>;
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { routeSectionForPath } from './routeModules';
describe('route module preloading', () => {
it('maps nested and parameterized URLs to the correct lazy module', () => {
expect(routeSectionForPath('/monitor')).toBe('monitor');
expect(routeSectionForPath('/vehicles/LTEST001?tab=telemetry')).toBe('vehicles');
expect(routeSectionForPath('/alerts/rules#editor')).toBe('alerts');
expect(routeSectionForPath('/not-a-platform-route')).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
type RouteModule = { default: ComponentType };
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
monitor: () => import('../pages/MonitorPage'),
vehicles: () => import('../pages/VehiclePage'),
tracks: () => import('../pages/TrackPage'),
history: () => import('../pages/HistoryPage'),
statistics: () => import('../pages/StatisticsPage'),
access: () => import('../pages/AccessPage'),
alerts: () => import('../pages/AlertsPage'),
operations: () => import('../pages/OperationsPage')
};
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
function loadRoute(section: RouteSection) {
const pending = pendingModules.get(section);
if (pending) return pending;
const next = importers[section]();
pendingModules.set(section, next);
void next.catch(() => pendingModules.delete(section));
return next;
}
export function routeSectionForPath(pathname: string): RouteSection | undefined {
const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined;
return section && section in importers ? section : undefined;
}
export function preloadRoute(pathname: string) {
const section = routeSectionForPath(pathname);
return section ? loadRoute(section) : undefined;
}
export const RoutePages = {
Monitor: lazy(() => loadRoute('monitor')),
Vehicles: lazy(() => loadRoute('vehicles')),
Tracks: lazy(() => loadRoute('tracks')),
History: lazy(() => loadRoute('history')),
Statistics: lazy(() => loadRoute('statistics')),
Access: lazy(() => loadRoute('access')),
Alerts: lazy(() => loadRoute('alerts')),
Operations: lazy(() => loadRoute('operations'))
} as const;

View File

@@ -1,7 +1,10 @@
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons'; import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) { export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
return <div className="v2-page-state"><span className="v2-spinner" />{label}</div>; return <section className="v2-page-state" role="status" aria-live="polite">
<header><span className="v2-spinner" /><div><strong>{label}</strong><small></small></div></header>
<div className="v2-page-skeleton"><i /><i /><i /><i /></div>
</section>;
} }
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) { export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {

View File

@@ -224,11 +224,29 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-refresh-cadence b { color: var(--v2-blue); font-weight: 700; } .v2-refresh-cadence b { color: var(--v2-blue); font-weight: 700; }
.v2-page-state, .v2-inline-state { display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--v2-muted); font-size: 12px; } .v2-page-state, .v2-inline-state { display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--v2-muted); font-size: 12px; }
.v2-page-state { min-height: calc(100vh - 64px); } .v2-page-state { min-height: 100%; flex-direction: column; gap: 18px; padding: clamp(18px, 3vw, 36px); }
.v2-page-state > header { display: flex; align-items: center; gap: 10px; }
.v2-page-state > header strong, .v2-page-state > header small { display: block; }
.v2-page-state > header strong { color: var(--v2-text); font-size: 13px; }
.v2-page-state > header small { margin-top: 3px; color: var(--v2-muted); font-size: 9px; }
.v2-page-skeleton { display: grid; width: min(920px, 100%); grid-template-columns: repeat(3,1fr); gap: 10px; }
.v2-page-skeleton i { min-height: 72px; border: 1px solid #edf1f6; border-radius: 10px; background: linear-gradient(100deg,#f4f7fb 25%,#edf3fa 38%,#f4f7fb 55%); background-size: 300% 100%; animation: v2-skeleton 1.4s ease infinite; }
.v2-page-skeleton i:last-child { min-height: 260px; grid-column: 1 / -1; }
.v2-route-error { display: grid; min-height: 100%; place-items: center; padding: 28px; background: #f6f8fb; }
.v2-route-error > span { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 16px; background: #fff1f2; color: #dc2626; font-size: 24px; }
.v2-route-error > div { width: min(560px,100%); text-align: center; }
.v2-route-error small { color: #b45309; font-size: 10px; font-weight: 700; }
.v2-route-error h2 { margin: 8px 0; color: var(--v2-text); font-size: 21px; }
.v2-route-error p { margin: 0 auto; color: var(--v2-muted); font-size: 12px; line-height: 1.7; }
.v2-route-error code { display: block; margin-top: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9b1c1c; font-size: 9px; }
.v2-route-error footer { display: flex; justify-content: center; gap: 10px; margin-top: 18px; }
.v2-route-error button, .v2-route-error a { display: inline-flex; height: 36px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 8px; background: #fff; padding: 0 14px; color: #526176; text-decoration: none; cursor: pointer; font-size: 11px; font-weight: 700; }
.v2-route-error button { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
.v2-inline-state { min-height: 76px; border: 1px dashed var(--v2-border); border-radius: 8px; padding: 12px; } .v2-inline-state { min-height: 76px; border: 1px dashed var(--v2-border); border-radius: 8px; padding: 12px; }
.v2-inline-state.is-error { justify-content: flex-start; min-height: 42px; border-style: solid; border-color: #fecaca; background: #fff5f5; color: #b42318; } .v2-inline-state.is-error { justify-content: flex-start; min-height: 42px; border-style: solid; border-color: #fecaca; background: #fff5f5; color: #b42318; }
.v2-inline-state button { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 11px; } .v2-inline-state button { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 11px; }
.v2-spinner { width: 14px; height: 14px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; } .v2-spinner { width: 14px; height: 14px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
@keyframes v2-skeleton { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
.v2-module-stage { margin: 18px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 28px; box-shadow: var(--v2-shadow); } .v2-module-stage { margin: 18px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 28px; box-shadow: var(--v2-shadow); }
.v2-module-stage h2 { margin: 0; font-size: 20px; } .v2-module-stage h2 { margin: 0; font-size: 20px; }
.v2-module-stage p { margin: 10px 0 0; color: var(--v2-muted); font-size: 13px; } .v2-module-stage p { margin: 10px 0 0; color: var(--v2-muted); font-size: 13px; }

View File

@@ -26,6 +26,31 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../
/opt/lingniu-vehicle-platform/env/platform.env /opt/lingniu-vehicle-platform/env/platform.env
``` ```
## Browser-safe release switch
The web application splits every major route into a hashed lazy-loaded asset. An already-open browser tab can therefore request the previous release's route asset after `current` has switched. Keep exactly one previous generation of original build assets in the new release before the atomic symlink switch:
```bash
CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web
NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web
# 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"
if [ -f "$CURRENT_WEB/.release-assets" ]; then
while IFS= read -r asset; do
test -n "$asset" || continue
mkdir -p "$NEW_WEB/assets/$(dirname "$asset")"
cp -n "$CURRENT_WEB/assets/$asset" "$NEW_WEB/assets/$asset"
done < "$CURRENT_WEB/.release-assets"
else
# One-time compatibility for releases created before the manifest existed.
cp -an "$CURRENT_WEB/assets/." "$NEW_WEB/assets/"
fi
```
Never overwrite a new hashed file. The `.release-assets` manifest prevents compatibility files from accumulating recursively: the next release copies only the immediately previous build's original assets. The React route boundary also detects a failed dynamic import, reloads once with a short cooldown and keeps the application shell usable if recovery still fails. Verify one old route asset and the new main asset both return HTTP 200 after the switch.
## Environment ## Environment
```text ```text
@@ -71,7 +96,7 @@ ALERT_STREAM_LATENESS_SEC=120
`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration. `AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.
`PLATFORM_RELEASE` is surfaced by `/api/ops/health.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment. `PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.
All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development. All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development.

View File

@@ -0,0 +1,39 @@
# Frontend production-readiness audit
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-15: route continuity and monitor memory
### Resolved in this round
| Risk | User-visible failure | Control | Evidence gate |
| --- | --- | --- | --- |
| 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 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 |
| QR generation resolves after the dialog unmounts | State update after lifecycle end | Cancel the asynchronous state write on cleanup | Monitor component test |
React documents that `lazy` caches the import promise and propagates a rejected module load to the nearest Error Boundary; this is why a route-level boundary is required rather than a loading fallback alone: <https://react.dev/reference/react/lazy>. React's Suspense documentation also distinguishes a loading fallback from error handling: <https://react.dev/reference/react/Suspense>.
The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.
### Remaining audit queue
1. Capture a Chrome heap profile across at least 50 `monitor -> history -> statistics -> tracks` cycles and inspect retained AMap objects and detached DOM nodes.
2. Bound or explicitly evict other high-cardinality query families, especially arbitrary history windows and track playback ranges.
3. Retire or migrate the legacy `App.tsx` integration suite so production V2 regressions and obsolete legacy UI expectations are reported separately.
4. Reduce the shared CSS and Semi UI payload; preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200.
## Release evidence template
- Commit and release identifier
- All non-legacy frontend tests
- Production V2 route tests
- TypeScript/Vite production build sizes
- Authenticated health response and `data.runtime.platformRelease`
- New main asset HTTP status
- Previous route asset HTTP status
- Browser route loop, blank-page check and console warnings/errors
- Desktop and mobile viewport screenshots