feat(monitor): preserve investigation context

This commit is contained in:
lingniu
2026-07-16 17:08:54 +08:00
parent 38b056f5f1
commit 1cfc9c2bdd
17 changed files with 426 additions and 37 deletions

View File

@@ -296,6 +296,29 @@ test('recovers in place after a transient AMap failure and renders data that arr
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument(); expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
}); });
test('restores the saved monitor viewport without forcing the selected vehicle zoom', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const bounds = '113.100000,23.000000,113.400000,23.300000';
render(<FleetMap
vehicles={[]}
monitorMap={pointMap}
selectedVin="LTEST000000000001"
initialViewport={{ zoom: 13, bounds }}
onSelect={() => undefined}
/>);
await waitFor(() => expect(mapOptions).toHaveLength(1));
expect(mapOptions[0]).toEqual(expect.objectContaining({
zoom: 13,
center: wgs84ToGcj02(113.25, 23.15)
}));
await waitFor(() => expect(markerSetMap).toHaveBeenCalled());
expect(setZoomAndCenter).not.toHaveBeenCalled();
expect(panTo).not.toHaveBeenCalled();
});
test('detaches AMap listeners and destroys the map on unmount', async () => { test('detaches AMap listeners and destroys the map on unmount', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) }; window.AMapLoader = { load: vi.fn(async () => amapMock()) };

View File

@@ -81,6 +81,14 @@ function viewportFromMap(map: AMapMap): MonitorViewport | null {
}; };
} }
function viewportCenter(viewport?: MonitorViewport) {
const values = viewport?.bounds.split(',').map(Number) ?? [];
if (values.length !== 4 || values.some((value) => !Number.isFinite(value))) return undefined;
const [west, south, east, north] = values;
if (west >= east || south >= north) return undefined;
return wgs84ToGcj02((west + east) / 2, (south + north) / 2);
}
function styleIndex(vehicle: VehicleRealtimeRow) { function styleIndex(vehicle: VehicleRealtimeRow) {
const status = vehicleStatus(vehicle); const status = vehicleStatus(vehicle);
return statusStyleIndex(status); return statusStyleIndex(status);
@@ -270,13 +278,14 @@ function densePlatePlacements(points: PlateLabelPoint[]) {
return placements; return placements;
} }
export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange }: { export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange, initialViewport }: {
vehicles: VehicleRealtimeRow[]; vehicles: VehicleRealtimeRow[];
selectedVin?: string; selectedVin?: string;
onSelect: (vehicle: VehicleRealtimeRow) => void; onSelect: (vehicle: VehicleRealtimeRow) => void;
monitorMap?: MonitorMapResponse; monitorMap?: MonitorMapResponse;
onSelectVin?: (vin: string) => void; onSelectVin?: (vin: string) => void;
onViewportChange?: (viewport: MonitorViewport) => void; onViewportChange?: (viewport: MonitorViewport) => void;
initialViewport?: MonitorViewport;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null); const mapRef = useRef<AMapMap | null>(null);
@@ -302,7 +311,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const viewportTimerRef = useRef<number | undefined>(undefined); const viewportTimerRef = useRef<number | undefined>(undefined);
const selectionKeyRef = useRef(''); const selectionKeyRef = useRef('');
const selectionPositionRef = useRef(''); const selectionPositionRef = useRef('');
const centeredVinRef = useRef(''); const initialViewportRef = useRef(initialViewport);
const centeredVinRef = useRef(initialViewport?.bounds ? selectedVin ?? '' : '');
const followSelectedRef = useRef(true); const followSelectedRef = useRef(true);
const programmaticFollowRef = useRef(false); const programmaticFollowRef = useRef(false);
const selectedVinRef = useRef(selectedVin); const selectedVinRef = useRef(selectedVin);
@@ -310,7 +320,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const [loadAttempt, setLoadAttempt] = useState(0); const [loadAttempt, setLoadAttempt] = useState(0);
const [showLabels, setShowLabels] = useState(true); const [showLabels, setShowLabels] = useState(true);
const [followSelected, setFollowSelected] = useState(true); const [followSelected, setFollowSelected] = useState(true);
const [mapZoom, setMapZoom] = useState(5); const [mapZoom, setMapZoom] = useState(initialViewport?.zoom ?? 5);
const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]); const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]);
const monitorMode = monitorMap?.mode; const monitorMode = monitorMap?.mode;
const monitorPoints = monitorMap?.points; const monitorPoints = monitorMap?.points;
@@ -354,11 +364,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
const initialSelection = initialSelectionRef.current; const initialSelection = initialSelectionRef.current;
const initialCenter = initialSelection const restoredCenter = viewportCenter(initialViewportRef.current);
const initialCenter = restoredCenter ?? (initialSelection
? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude) ? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude)
: wgs84ToGcj02(105.4, 35.9); : wgs84ToGcj02(105.4, 35.9));
const map = new AMap.Map(containerRef.current, { const map = new AMap.Map(containerRef.current, {
zoom: initialSelection ? 13 : 5, zoom: initialViewportRef.current?.zoom ?? (initialSelection ? 13 : 5),
center: initialCenter, center: initialCenter,
viewMode: '2D', viewMode: '2D',
mapStyle: 'amap://styles/whitesmoke', mapStyle: 'amap://styles/whitesmoke',
@@ -648,7 +659,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
}, [fallbackPoints, mapZoom, monitorPoints, selectedTarget, selectedVin, showLabels, state]); }, [fallbackPoints, mapZoom, monitorPoints, selectedTarget, selectedVin, showLabels, state]);
useEffect(() => { useEffect(() => {
if (!selectedVin || !mapRef.current || !amapRef.current) { if (!selectedVin) {
selectionRef.current?.setMap?.(null); selectionRef.current?.setMap?.(null);
selectionRef.current = null; selectionRef.current = null;
selectionKeyRef.current = ''; selectionKeyRef.current = '';
@@ -658,6 +669,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
setFollowSelected(false); setFollowSelected(false);
return; return;
} }
if (!mapRef.current || !amapRef.current) return;
const target = selectedTarget; const target = selectedTarget;
if (!target) return; if (!target) return;
const label = escapeHtml(target.plate || target.vin); const label = escapeHtml(target.plate || target.vin);

View File

@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types'; import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig'; import { ROUTER_FUTURE } from '../routing/routerConfig';
import HistoryPage from './HistoryPage'; import HistoryPage from './HistoryPage';
@@ -44,12 +45,32 @@ function historySeries(vin: string, plate: string, asOf: string): HistorySeriesR
}; };
} }
function renderPage() { function renderPage(initialEntry = '/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00']}><HistoryPage /></MemoryRouter></QueryClientProvider>); const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><HistoryPage /></MemoryRouter></QueryClientProvider>);
return { ...view, client }; return { ...view, client };
} }
test('preserves the exact monitor return after changing history filters', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
const monitorPath = buildMonitorPath({
mode: 'list', keyword: '粤A12345', protocol: '', status: '', selectedVin: 'OLDVIN', detailOpen: false,
viewport: { zoom: 12.5, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true
});
renderPage(withMonitorReturn('/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00', monitorPath));
const returnLink = await screen.findByRole('link', { name: /返回全局监控/ });
expect(returnLink).toHaveAttribute('href', monitorPath);
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => { test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => {
let resolveNewData!: (value: HistoryDataResponse) => void; let resolveNewData!: (value: HistoryDataResponse) => void;
let resolveNewSeries!: (value: HistorySeriesResponse) => void; let resolveNewSeries!: (value: HistorySeriesResponse) => void;

View File

@@ -7,9 +7,11 @@ import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, History
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
import { downloadBlob } from '../domain/download'; import { downloadBlob } from '../domain/download';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
import { canOperate } from '../auth/session'; import { canOperate } from '../auth/session';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
@@ -112,6 +114,7 @@ export default function HistoryPage() {
const { session } = usePlatformSession(); const { session } = usePlatformSession();
const exportAllowed = canOperate(session) || session.role === 'customer'; const exportAllowed = canOperate(session) || session.role === 'customer';
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const today = useMemo(currentHistoryWindow, []); const today = useMemo(currentHistoryWindow, []);
const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' }; const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' };
const [draft, setDraft] = useState(initial); const [draft, setDraft] = useState(initial);
@@ -176,9 +179,9 @@ export default function HistoryPage() {
if (next.dateFrom) url.set('dateFrom', next.dateFrom); if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo); if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol); if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true }); setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
}; };
const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams({}, { replace: true }); }; const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams(preserveMonitorReturn(new URLSearchParams(), monitorReturn), { replace: true }); };
const toggleMetric = (key: string) => setVisibleByCategory((current) => { const toggleMetric = (key: string) => setVisibleByCategory((current) => {
const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key); const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key]; const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key];
@@ -189,6 +192,7 @@ export default function HistoryPage() {
const page = Math.floor(offset / limit) + 1; const page = Math.floor(offset / limit) + 1;
return <div className="v2-history-page"> return <div className="v2-history-page">
<MonitorReturnBar />
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button> <button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}> <form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN多台用逗号分隔" /></div></label> <label className="v2-history-vehicles"><span> 5 </span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN多台用逗号分隔" /></div></label>

View File

@@ -27,9 +27,9 @@ const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: fals
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
vi.mock('../map/FleetMap', () => ({ vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => { FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => {
fleetMapRenderSpy(selectedVin); fleetMapRenderSpy(selectedVin, initialViewport);
return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''}> return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''} data-initial-zoom={initialViewport?.zoom ?? ''} data-initial-bounds={initialViewport?.bounds ?? ''}>
<button type="button" onClick={() => onSelectVin?.('LTEST000000000002')}></button> <button type="button" onClick={() => onSelectVin?.('LTEST000000000002')}></button>
</div>; </div>;
} }
@@ -136,6 +136,28 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(secondVehicle).toHaveClass('is-selected'); expect(secondVehicle).toHaveClass('is-selected');
}); });
test('restores URL-backed monitor context and carries it into every vehicle workflow', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const entry = '/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online&selectedVin=LTEST000000000001&detail=collapsed&zoom=13&bounds=113.100000%2C23.000000%2C113.400000%2C23.300000';
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[entry]}><MonitorPage /></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345');
expect(screen.getByRole('combobox', { name: '协议' })).toHaveValue('JT808');
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveValue('online');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-zoom', '13');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
fireEvent.click(screen.getByRole('button', { name: '展开车辆详情' }));
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询']) {
const target = new URL(screen.getByRole('link', { name }).getAttribute('href')!, 'https://vehicle-platform.invalid');
const monitorReturn = target.searchParams.get('monitorReturn');
expect(monitorReturn).toContain('/monitor?');
expect(monitorReturn).toContain('selectedVin=LTEST000000000001');
expect(monitorReturn).toContain('zoom=13');
}
});
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => { test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
const row = vehicles[0]; const row = vehicles[0];
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 }); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });

View File

@@ -1,7 +1,7 @@
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types'; import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap'; import { FleetMap } from '../map/FleetMap';
@@ -10,6 +10,7 @@ import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor'; import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData'; import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT']; const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location']; const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
@@ -178,10 +179,12 @@ const MemoFleetMap = memo(FleetMap);
function VehicleDetailCard({ function VehicleDetailCard({
vehicle, vehicle,
monitorReturn,
onCollapse, onCollapse,
onClear onClear
}: { }: {
vehicle: VehicleRealtimeRow; vehicle: VehicleRealtimeRow;
monitorReturn: string;
onCollapse: () => void; onCollapse: () => void;
onClear: () => void; onClear: () => void;
}) { }) {
@@ -204,9 +207,10 @@ function VehicleDetailCard({
<small>{vehicle.vin}</small> <small>{vehicle.vin}</small>
</div> </div>
<div className="v2-detail-actions"> <div className="v2-detail-actions">
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}></Link> <Link to={withMonitorReturn(`/vehicles/${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}></Link> <Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}></Link> <Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
</div> </div>
<section> <section>
<h3></h3> <h3></h3>
@@ -246,23 +250,33 @@ function VehicleDetailCard({
} }
export default function MonitorPage() { export default function MonitorPage() {
const [mode, setMode] = useState<'map' | 'list'>('map'); const [routeParams, setRouteParams] = useSearchParams();
const initialContextRef = useRef(parseMonitorRouteContext(routeParams));
const initialContext = initialContextRef.current;
const [mode, setMode] = useState<'map' | 'list'>(initialContext.mode);
const [mobileEntryOpen, setMobileEntryOpen] = useState(false); const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
const [listOffset, setListOffset] = useState(0); const [listOffset, setListOffset] = useState(initialContext.listOffset);
const [listLimit, setListLimit] = useState(50); const [listLimit, setListLimit] = useState(initialContext.listLimit);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState(initialContext.keyword);
const [batchSearchOpen, setBatchSearchOpen] = useState(false); const [batchSearchOpen, setBatchSearchOpen] = useState(false);
const deferredKeyword = useDeferredValue(keyword); const deferredKeyword = useDeferredValue(keyword);
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]); const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
const [protocol, setProtocol] = useState(''); const [protocol, setProtocol] = useState(initialContext.protocol);
const [status, setStatus] = useState(''); const [status, setStatus] = useState(initialContext.status);
const [selectedVin, setSelectedVin] = useState(''); const [selectedVin, setSelectedVin] = useState(initialContext.selectedVin);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(initialContext.detailOpen);
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' }); const [viewport, setViewport] = useState<MonitorViewport>(initialContext.viewport);
const updateViewport = useCallback((next: MonitorViewport) => { const updateViewport = useCallback((next: MonitorViewport) => {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next); setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []); }, []);
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]); const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
const monitorReturn = useMemo(() => buildMonitorPath({
mode, keyword, protocol, status, selectedVin, detailOpen, viewport, listOffset, listLimit
}), [detailOpen, keyword, listLimit, listOffset, mode, protocol, selectedVin, status, viewport]);
useEffect(() => {
const next = new URL(monitorReturn, 'https://vehicle-platform.invalid').searchParams;
if (next.toString() !== routeParams.toString()) setRouteParams(next, { replace: true });
}, [monitorReturn, routeParams, setRouteParams]);
const listScope = useMemo(() => monitorFilterScope(filters), [filters]); const listScope = useMemo(() => monitorFilterScope(filters), [filters]);
const listParams = useMemo(() => { const listParams = useMemo(() => {
const params = monitorQueryParams(filters, listLimit); const params = monitorQueryParams(filters, listLimit);
@@ -394,10 +408,12 @@ export default function MonitorPage() {
onSelect={selectMapVehicle} onSelect={selectMapVehicle}
onSelectVin={selectVehicle} onSelectVin={selectVehicle}
onViewportChange={updateViewport} onViewportChange={updateViewport}
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
/> />
{selected && detailOpen ? ( {selected && detailOpen ? (
<VehicleDetailCard <VehicleDetailCard
vehicle={selected} vehicle={selected}
monitorReturn={monitorReturn}
onCollapse={collapseDetail} onCollapse={collapseDetail}
onClear={clearSelection} onClear={clearSelection}
/> />

View File

@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage'; import StatisticsPage from './StatisticsPage';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig'; import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() })); const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
@@ -85,6 +86,20 @@ test('defaults to seven days and supports today and yesterday shortcuts', async
}); });
}); });
test('preserves the exact monitor return after a mileage date shortcut', async () => {
prepareData();
const monitorPath = buildMonitorPath({
mode: 'list', keyword: '', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: false,
viewport: { zoom: 5, bounds: '' }, listOffset: 40, listLimit: 20, hasViewport: false
});
renderPage(withMonitorReturn('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14', monitorPath));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
fireEvent.click(screen.getByRole('button', { name: '今天' }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => { test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => {
prepareData(); prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14'); const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14');

View File

@@ -7,7 +7,9 @@ import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../..
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport'; import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
import { formatZhNumber } from '../domain/formatters'; import { formatZhNumber } from '../domain/formatters';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
const DAY = 86_400_000; const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000; const DETAIL_LIMIT = 10_000;
@@ -257,6 +259,7 @@ function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: st
export default function StatisticsPage() { export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams)); const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams)); const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -324,9 +327,9 @@ export default function StatisticsPage() {
const error = mileageDateRangeError(draft); const error = mileageDateRangeError(draft);
setValidationError(error); setValidationError(error);
if (error) return; if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); setFiltersCollapsed(true); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true);
}; };
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); }; const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); };
const todayRange = defaultWindow(1); const todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1); const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7); const sevenDayRange = defaultWindow(7);
@@ -418,6 +421,7 @@ export default function StatisticsPage() {
}; };
return <div className="v2-mileage-page"> return <div className="v2-mileage-page">
<MonitorReturnBar />
<section className="v2-mileage-query-panel"> <section className="v2-mileage-query-panel">
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button> <button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}> <form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { MemoryRouter, useNavigate } from 'react-router-dom'; import { MemoryRouter, useNavigate } from 'react-router-dom';
import type { TrackPlaybackResponse } from '../../api/types'; import type { TrackPlaybackResponse } from '../../api/types';
import TrackPage from './TrackPage'; import TrackPage from './TrackPage';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig'; import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() })); const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
@@ -43,6 +44,22 @@ function TrackNavigationHarness() {
return <><button type="button" onClick={() => navigate('/tracks')}></button><button type="button" onClick={() => navigate(-1)}>退</button><button type="button" onClick={() => navigate(1)}></button><TrackPage /></>; return <><button type="button" onClick={() => navigate('/tracks')}></button><button type="button" onClick={() => navigate(-1)}>退</button><button type="button" onClick={() => navigate(1)}></button><TrackPage /></>;
} }
test('preserves the exact monitor return after querying another track range', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const monitorPath = buildMonitorPath({
mode: 'map', keyword: '粤A12345', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: true,
viewport: { zoom: 14, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true
});
const initialEntry = withMonitorReturn('/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59', monitorPath);
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
fireEvent.change(screen.getByLabelText('数据来源'), { target: { value: 'JT808' } });
fireEvent.click(screen.getByRole('button', { name: /查询轨迹/ }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => { test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>); render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);

View File

@@ -10,7 +10,9 @@ import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../.
import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, type TrackAddressCoordinate } from '../domain/track'; import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, type TrackAddressCoordinate } from '../domain/track';
import { TrackMap } from '../map/TrackMap'; import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { QUERY_MEMORY } from '../queryPolicy'; import { QUERY_MEMORY } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
const speedOptions = [0.5, 1, 2, 4] as const; const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number]; type PlaybackSpeed = (typeof speedOptions)[number];
@@ -188,6 +190,7 @@ const SegmentRail = memo(function SegmentRail({ track, onSelectIndex }: { track:
export default function TrackPage() { export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const fallback = useMemo(defaultTrackWindow, []); const fallback = useMemo(defaultTrackWindow, []);
const routeKey = searchParams.toString(); const routeKey = searchParams.toString();
const criteria = useMemo<Draft>(() => { const criteria = useMemo<Draft>(() => {
@@ -273,8 +276,8 @@ export default function TrackPage() {
if (next.dateFrom) url.set('dateFrom', next.dateFrom); if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo); if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol); if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true }); setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
}, [draft, setSearchParams]); }, [draft, monitorReturn, setSearchParams]);
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]); const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
const collapseRail = useCallback(() => setRailCollapsed(true), []); const collapseRail = useCallback(() => setRailCollapsed(true), []);
const togglePlayback = () => { const togglePlayback = () => {
@@ -286,6 +289,7 @@ export default function TrackPage() {
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0; const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}> return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
<MonitorReturnBar />
<TrackRail draft={draft} track={track} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={collapseRail} /> <TrackRail draft={draft} track={track} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={collapseRail} />
<section className="v2-track-stage"> <section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} /> <TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />

View File

@@ -4,6 +4,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types'; import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig'; import { ROUTER_FUTURE } from '../routing/routerConfig';
import VehiclePage from './VehiclePage'; import VehiclePage from './VehiclePage';
@@ -77,3 +78,23 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
fireEvent.click(screen.getByTitle('展开全部位置来源')); fireEvent.click(screen.getByTitle('展开全部位置来源'));
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
}); });
test('keeps the monitor return on the vehicle page and its nested investigation links', async () => {
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const monitorPath = buildMonitorPath({
mode: 'map', keyword: '', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: true,
viewport: { zoom: 13, bounds: '113.1,23.0,113.4,23.3' }, listOffset: 0, listLimit: 50, hasViewport: true
});
const initialEntry = withMonitorReturn(`/vehicles/${initialRealtime.vin}`, monitorPath);
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
const trackLink = screen.getByRole('link', { name: /轨迹回放/ });
const historyLink = screen.getByRole('link', { name: /历史数据/ });
expect(new URL(trackLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
expect(new URL(historyLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
});

View File

@@ -4,7 +4,7 @@ import {
IconMapPin, IconSearch, IconTickCircle IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types'; import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
@@ -15,7 +15,9 @@ import { formatZhNumber } from '../domain/formatters';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync'; import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
import { FleetMap } from '../map/FleetMap'; import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState'; import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
function fmt(value?: string) { return value?.trim() || '—'; } function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; } function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
@@ -172,7 +174,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
</section>; </section>;
} }
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) { function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) {
const { session } = usePlatformSession(); const { session } = usePlatformSession();
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const realtime = liveRealtime ?? detail.realtimeSummary; const realtime = liveRealtime ?? detail.realtimeSummary;
@@ -180,10 +182,11 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
const mapVehicles = realtime ? [realtime] : []; const mapVehicles = realtime ? [realtime] : [];
const lastMileage = detail.mileage.items[0]; const lastMileage = detail.mileage.items[0];
return <div className="v2-vehicle-record-page"> return <div className="v2-vehicle-record-page">
<MonitorReturnBar />
<section className="v2-identity-band"> <section className="v2-identity-band">
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div> <div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
<div className="v2-identity-meta"><div><small></small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small></small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div> <div className="v2-identity-meta"><div><small></small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small></small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div>
<div className="v2-identity-actions"><Link to={`/tracks?vin=${encodeURIComponent(detail.vin)}`}><IconMapPin /></Link><Link to={`/history?vin=${encodeURIComponent(detail.vin)}`}><IconCalendar /></Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link></div> <div className="v2-identity-actions"><Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin /></Link><Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar /></Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link></div>
</section> </section>
<div className="v2-record-grid"> <div className="v2-record-grid">
@@ -201,6 +204,8 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
export default function VehiclePage() { export default function VehiclePage() {
const { vin } = useParams(); const { vin } = useParams();
const [searchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
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 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 resolvedVin = query.data?.lookupResolved ? query.data.vin : ''; const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
const realtime = useQuery({ const realtime = useQuery({
@@ -217,5 +222,5 @@ export default function VehiclePage() {
if (query.isPending) return <PageLoading />; if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>; if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>; if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />; return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
} }

View File

@@ -0,0 +1,57 @@
import { expect, test } from 'vitest';
import {
buildMonitorPath,
monitorReturnFromParams,
parseMonitorRouteContext,
preserveMonitorReturn,
validatedMonitorReturn,
withMonitorReturn
} from './monitorContext';
test('round trips the selected vehicle, filters, panel state, pagination and map viewport', () => {
const path = buildMonitorPath({
mode: 'list',
keyword: '粤A12345',
protocol: 'JT808',
status: 'online',
selectedVin: 'LTEST000000000001',
detailOpen: false,
viewport: { zoom: 13.25, bounds: '113.100000,23.000000,113.400000,23.300000' },
listOffset: 100,
listLimit: 100
});
const parsed = parseMonitorRouteContext(new URL(path, 'https://vehicle-platform.invalid').searchParams);
expect(parsed).toMatchObject({
mode: 'list',
keyword: '粤A12345',
protocol: 'JT808',
status: 'online',
selectedVin: 'LTEST000000000001',
detailOpen: false,
viewport: { zoom: 13.25, bounds: '113.100000,23.000000,113.400000,23.300000' },
listOffset: 100,
listLimit: 100,
hasViewport: true
});
});
test('carries only an internal monitor return and preserves it through child query changes', () => {
const monitor = '/monitor?selectedVin=LTEST000000000001&detail=open&zoom=15&bounds=113.1%2C23%2C113.4%2C23.3';
const child = withMonitorReturn('/tracks?vin=LTEST000000000001', monitor);
const childParams = new URL(child, 'https://vehicle-platform.invalid').searchParams;
const monitorReturn = monitorReturnFromParams(childParams);
const changed = preserveMonitorReturn(new URLSearchParams({ keyword: 'LTEST000000000001', protocol: 'JT808' }), monitorReturn);
expect(monitorReturn).toContain('/monitor?');
expect(changed.get('monitorReturn')).toBe(monitorReturn);
expect(validatedMonitorReturn('https://attacker.invalid/monitor?selectedVin=SECRET')).toBe('');
expect(validatedMonitorReturn('/vehicles/LTEST')).toBe('');
});
test('rejects malformed viewport input instead of restoring unsafe map state', () => {
const context = parseMonitorRouteContext('?zoom=999&bounds=120,20,110,10&listOffset=-1&listLimit=500');
expect(context.viewport).toEqual({ zoom: 5, bounds: '' });
expect(context.listOffset).toBe(0);
expect(context.listLimit).toBe(50);
});

View File

@@ -0,0 +1,116 @@
import type { MonitorViewport } from '../hooks/useMonitorData';
export const MONITOR_RETURN_PARAM = 'monitorReturn';
export type MonitorRouteContext = {
mode: 'map' | 'list';
keyword: string;
protocol: string;
status: string;
selectedVin: string;
detailOpen: boolean;
viewport: MonitorViewport;
listOffset: number;
listLimit: number;
hasViewport: boolean;
};
const DEFAULT_CONTEXT: MonitorRouteContext = {
mode: 'map',
keyword: '',
protocol: '',
status: '',
selectedVin: '',
detailOpen: false,
viewport: { zoom: 5, bounds: '' },
listOffset: 0,
listLimit: 50,
hasViewport: false
};
function integer(value: string | null, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
}
function decimal(value: string | null, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
}
function validBounds(value: string | null) {
if (!value) return '';
const coordinates = value.split(',').map(Number);
if (coordinates.length !== 4 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) return '';
const [west, south, east, north] = coordinates;
if (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) return '';
return coordinates.map((coordinate) => coordinate.toFixed(6)).join(',');
}
export function parseMonitorRouteContext(search: string | URLSearchParams): MonitorRouteContext {
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
const selectedVin = params.get('selectedVin')?.trim().toUpperCase() ?? '';
const bounds = validBounds(params.get('bounds'));
const hasViewport = params.has('zoom') || Boolean(bounds);
return {
mode: params.get('mode') === 'list' ? 'list' : 'map',
keyword: params.get('keyword')?.trim() ?? '',
protocol: params.get('protocol')?.trim() ?? '',
status: params.get('status')?.trim() ?? '',
selectedVin,
detailOpen: Boolean(selectedVin) && params.get('detail') !== 'collapsed',
viewport: {
zoom: decimal(params.get('zoom'), DEFAULT_CONTEXT.viewport.zoom, 3, 20),
bounds
},
listOffset: integer(params.get('listOffset'), 0, 0, 1_000_000),
listLimit: [20, 50, 100].includes(Number(params.get('listLimit'))) ? Number(params.get('listLimit')) : 50,
hasViewport
};
}
export function buildMonitorPath(context: Omit<MonitorRouteContext, 'hasViewport'> | MonitorRouteContext) {
const params = new URLSearchParams();
if (context.mode !== 'map') params.set('mode', context.mode);
if (context.keyword.trim()) params.set('keyword', context.keyword.trim());
if (context.protocol) params.set('protocol', context.protocol);
if (context.status) params.set('status', context.status);
if (context.selectedVin) {
params.set('selectedVin', context.selectedVin);
params.set('detail', context.detailOpen ? 'open' : 'collapsed');
}
params.set('zoom', String(Math.round(context.viewport.zoom * 100) / 100));
if (validBounds(context.viewport.bounds)) params.set('bounds', validBounds(context.viewport.bounds));
if (context.listOffset) params.set('listOffset', String(context.listOffset));
if (context.listLimit !== 50) params.set('listLimit', String(context.listLimit));
const query = params.toString();
return query ? `/monitor?${query}` : '/monitor';
}
export function validatedMonitorReturn(value?: string | null) {
if (!value) return '';
try {
const parsed = new URL(value, 'https://vehicle-platform.invalid');
if (parsed.origin !== 'https://vehicle-platform.invalid' || parsed.pathname !== '/monitor') return '';
return buildMonitorPath(parseMonitorRouteContext(parsed.searchParams));
} catch {
return '';
}
}
export function withMonitorReturn(path: string, monitorPath: string) {
const target = new URL(path, 'https://vehicle-platform.invalid');
const validated = validatedMonitorReturn(monitorPath);
if (validated) target.searchParams.set(MONITOR_RETURN_PARAM, validated);
return `${target.pathname}${target.search}${target.hash}`;
}
export function monitorReturnFromParams(params: URLSearchParams) {
return validatedMonitorReturn(params.get(MONITOR_RETURN_PARAM));
}
export function preserveMonitorReturn(params: URLSearchParams, monitorReturn: string) {
if (monitorReturn) params.set(MONITOR_RETURN_PARAM, monitorReturn);
else params.delete(MONITOR_RETURN_PARAM);
return params;
}

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { withMonitorReturn } from '../routing/monitorContext';
import { MonitorReturnBar } from './MonitorReturnBar';
test('shows a deterministic monitor return only for monitor-originated child routes', () => {
const child = withMonitorReturn('/history?vin=LTEST000000000001', '/monitor?selectedVin=LTEST000000000001&detail=collapsed&zoom=13&bounds=113.1%2C23%2C113.4%2C23.3');
const view = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={[child]}><MonitorReturnBar /></MemoryRouter>);
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', expect.stringContaining('/monitor?'));
expect(screen.getByText(/LTEST000000000001/)).toBeInTheDocument();
expect(screen.getByText(/Zoom 13 · 详情收起/)).toBeInTheDocument();
view.unmount();
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?vin=LTEST000000000001']}><MonitorReturnBar /></MemoryRouter>);
expect(screen.queryByRole('link', { name: /返回全局监控/ })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,21 @@
import { IconArrowLeft, IconMapPin } from '@douyinfe/semi-icons';
import { Link, useSearchParams } from 'react-router-dom';
import { monitorReturnFromParams, parseMonitorRouteContext } from '../routing/monitorContext';
export function MonitorReturnBar() {
const [searchParams] = useSearchParams();
const returnTo = monitorReturnFromParams(searchParams);
if (!returnTo) return null;
const context = parseMonitorRouteContext(new URL(returnTo, 'https://vehicle-platform.invalid').searchParams);
const scope = [
context.selectedVin || '',
context.keyword && context.keyword !== context.selectedVin ? context.keyword : '',
context.protocol,
context.status
].filter(Boolean).join(' · ');
return <section className="v2-monitor-return" aria-label="全局监控返回上下文">
<Link to={returnTo} replace><IconArrowLeft /></Link>
<span><IconMapPin />{scope || '恢复离开前的地图与筛选状态'}</span>
<em>Zoom {context.viewport.zoom} · {context.detailOpen ? '详情展开' : context.selectedVin ? '详情收起' : '未选车辆'}</em>
</section>;
}

View File

@@ -18,6 +18,12 @@ body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family:
button, input, select { font: inherit; } button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; } button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-return { display: flex; min-height: 38px; flex: 0 0 auto; align-items: center; gap: 12px; border: 1px solid #cfe0f6; border-radius: 9px; background: linear-gradient(90deg,#f2f7ff,#fff); padding: 6px 10px; color: #51637b; box-shadow: 0 3px 12px rgba(31,69,119,.06); font-size: 9px; }
.v2-monitor-return a { display: inline-flex; height: 28px; flex: 0 0 auto; align-items: center; gap: 5px; border-radius: 7px; background: #1769d2; padding: 0 10px; color: #fff; text-decoration: none; font-weight: 700; }
.v2-monitor-return span { display: inline-flex; min-width: 0; align-items: center; gap: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-return em { margin-left: auto; flex: 0 0 auto; color: #78869a; font-size: 8px; font-style: normal; }
.v2-track-page > .v2-monitor-return { z-index: 30; grid-column: 1 / -1; }
.v2-auth-screen { display: grid; min-height: 100vh; grid-template-columns: minmax(360px, 1.15fr) minmax(420px, .85fr); align-items: center; background: #f3f6fa; padding: clamp(28px, 5vw, 80px); } .v2-auth-screen { display: grid; min-height: 100vh; grid-template-columns: minmax(360px, 1.15fr) minmax(420px, .85fr); align-items: center; background: #f3f6fa; padding: clamp(28px, 5vw, 80px); }
.v2-auth-intro { max-width: 640px; padding: 36px 8vw 36px 4vw; } .v2-auth-intro { max-width: 640px; padding: 36px 8vw 36px 4vw; }
.v2-auth-intro > img { width: 176px; } .v2-auth-intro > img { width: 176px; }
@@ -1720,7 +1726,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
} }
/* Track replay command center — map-first layout based on the G7 reference. */ /* Track replay command center — map-first layout based on the G7 reference. */
.v2-track-page { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: 320px minmax(0, 1fr); gap: 0; overflow: hidden; padding: 0; background: #e9eef4; } .v2-track-page { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: 320px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 0; overflow: hidden; padding: 0; background: #e9eef4; }
.v2-track-page > .v2-monitor-return { margin: 7px 8px; }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); } .v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-rail { position: relative; z-index: 12; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: visible; border-right: 1px solid #d9e1ea; background: #fff; box-shadow: 8px 0 24px rgba(30, 45, 66, .07); transition: transform .18s ease, opacity .18s ease; } .v2-track-rail { position: relative; z-index: 12; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: visible; border-right: 1px solid #d9e1ea; background: #fff; box-shadow: 8px 0 24px rgba(30, 45, 66, .07); transition: transform .18s ease, opacity .18s ease; }
.v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateX(-100%); } .v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateX(-100%); }
@@ -2065,6 +2072,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
@media (max-width: 700px) { @media (max-width: 700px) {
.v2-track-page { display: block; } .v2-track-page { display: block; }
.v2-track-page > .v2-monitor-return { margin: 7px; }
.v2-monitor-return { min-height: 34px; gap: 7px; padding: 4px 6px; }
.v2-monitor-return a { height: 27px; padding: 0 8px; }
.v2-monitor-return span { flex: 1; }
.v2-monitor-return em { display: none; }
.v2-track-query > label, .v2-track-date-grid label { font-size: 12px; } .v2-track-query > label, .v2-track-date-grid label { font-size: 12px; }
.v2-track-query-button { font-size: 13px; } .v2-track-query-button { font-size: 13px; }
.v2-alert-tabs { overflow-x: auto; } .v2-alert-tabs { overflow-x: auto; }