feat(monitor): preserve investigation context
This commit is contained in:
@@ -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()) };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(); }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
116
vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts
Normal file
116
vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user