feat(monitor): preserve investigation context
This commit is contained in:
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
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 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 };
|
||||
}
|
||||
|
||||
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 () => {
|
||||
let resolveNewData!: (value: HistoryDataResponse) => 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 { downloadBlob } from '../domain/download';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
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 });
|
||||
|
||||
@@ -112,6 +114,7 @@ export default function HistoryPage() {
|
||||
const { session } = usePlatformSession();
|
||||
const exportAllowed = canOperate(session) || session.role === 'customer';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
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 [draft, setDraft] = useState(initial);
|
||||
@@ -176,9 +179,9 @@ export default function HistoryPage() {
|
||||
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
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 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];
|
||||
@@ -189,6 +192,7 @@ export default function HistoryPage() {
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -27,9 +27,9 @@ const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: fals
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
|
||||
fleetMapRenderSpy(selectedVin);
|
||||
return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''}>
|
||||
FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => {
|
||||
fleetMapRenderSpy(selectedVin, initialViewport);
|
||||
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>
|
||||
</div>;
|
||||
}
|
||||
@@ -136,6 +136,28 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
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 () => {
|
||||
const row = vehicles[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 { useQuery } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
@@ -10,6 +10,7 @@ import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel
|
||||
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 { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
|
||||
|
||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
|
||||
@@ -178,10 +179,12 @@ const MemoFleetMap = memo(FleetMap);
|
||||
|
||||
function VehicleDetailCard({
|
||||
vehicle,
|
||||
monitorReturn,
|
||||
onCollapse,
|
||||
onClear
|
||||
}: {
|
||||
vehicle: VehicleRealtimeRow;
|
||||
monitorReturn: string;
|
||||
onCollapse: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
@@ -204,9 +207,10 @@ function VehicleDetailCard({
|
||||
<small>{vehicle.vin}</small>
|
||||
</div>
|
||||
<div className="v2-detail-actions">
|
||||
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}>单车详情</Link>
|
||||
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}>轨迹回放</Link>
|
||||
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}>历史数据</Link>
|
||||
<Link to={withMonitorReturn(`/vehicles/${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>单车详情</Link>
|
||||
<Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>轨迹回放</Link>
|
||||
<Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>历史数据</Link>
|
||||
<Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>里程查询</Link>
|
||||
</div>
|
||||
<section>
|
||||
<h3>车辆信息</h3>
|
||||
@@ -246,23 +250,33 @@ function VehicleDetailCard({
|
||||
}
|
||||
|
||||
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 [listOffset, setListOffset] = useState(0);
|
||||
const [listLimit, setListLimit] = useState(50);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [listOffset, setListOffset] = useState(initialContext.listOffset);
|
||||
const [listLimit, setListLimit] = useState(initialContext.listLimit);
|
||||
const [keyword, setKeyword] = useState(initialContext.keyword);
|
||||
const [batchSearchOpen, setBatchSearchOpen] = useState(false);
|
||||
const deferredKeyword = useDeferredValue(keyword);
|
||||
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
|
||||
const [protocol, setProtocol] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState('');
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' });
|
||||
const [protocol, setProtocol] = useState(initialContext.protocol);
|
||||
const [status, setStatus] = useState(initialContext.status);
|
||||
const [selectedVin, setSelectedVin] = useState(initialContext.selectedVin);
|
||||
const [detailOpen, setDetailOpen] = useState(initialContext.detailOpen);
|
||||
const [viewport, setViewport] = useState<MonitorViewport>(initialContext.viewport);
|
||||
const updateViewport = useCallback((next: MonitorViewport) => {
|
||||
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
|
||||
}, []);
|
||||
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 listParams = useMemo(() => {
|
||||
const params = monitorQueryParams(filters, listLimit);
|
||||
@@ -394,10 +408,12 @@ export default function MonitorPage() {
|
||||
onSelect={selectMapVehicle}
|
||||
onSelectVin={selectVehicle}
|
||||
onViewportChange={updateViewport}
|
||||
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
|
||||
/>
|
||||
{selected && detailOpen ? (
|
||||
<VehicleDetailCard
|
||||
vehicle={selected}
|
||||
monitorReturn={monitorReturn}
|
||||
onCollapse={collapseDetail}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage from './StatisticsPage';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
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 () => {
|
||||
prepareData();
|
||||
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 { formatZhNumber } from '../domain/formatters';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const DETAIL_LIMIT = 10_000;
|
||||
@@ -257,6 +259,7 @@ function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: st
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -324,9 +327,9 @@ export default function StatisticsPage() {
|
||||
const error = mileageDateRangeError(draft);
|
||||
setValidationError(error);
|
||||
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 yesterdayRange = defaultWindow(1, 1);
|
||||
const sevenDayRange = defaultWindow(7);
|
||||
@@ -418,6 +421,7 @@ export default function StatisticsPage() {
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-page">
|
||||
<MonitorReturnBar />
|
||||
<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>
|
||||
<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 type { TrackPlaybackResponse } from '../../api/types';
|
||||
import TrackPage from './TrackPage';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
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 /></>;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
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>);
|
||||
|
||||
@@ -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 { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
|
||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||
type PlaybackSpeed = (typeof speedOptions)[number];
|
||||
@@ -188,6 +190,7 @@ const SegmentRail = memo(function SegmentRail({ track, onSelectIndex }: { track:
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const fallback = useMemo(defaultTrackWindow, []);
|
||||
const routeKey = searchParams.toString();
|
||||
const criteria = useMemo<Draft>(() => {
|
||||
@@ -273,8 +276,8 @@ export default function TrackPage() {
|
||||
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setSearchParams(url, { replace: true });
|
||||
}, [draft, setSearchParams]);
|
||||
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
|
||||
}, [draft, monitorReturn, setSearchParams]);
|
||||
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 togglePlayback = () => {
|
||||
@@ -286,6 +289,7 @@ export default function TrackPage() {
|
||||
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
|
||||
|
||||
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} />
|
||||
<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} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import VehiclePage from './VehiclePage';
|
||||
|
||||
@@ -77,3 +78,23 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
fireEvent.click(screen.getByTitle('展开全部位置来源'));
|
||||
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
|
||||
} from '@douyinfe/semi-icons';
|
||||
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 type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
@@ -15,7 +15,9 @@ import { formatZhNumber } from '../domain/formatters';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { InlineError, PageLoading } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
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>;
|
||||
}
|
||||
|
||||
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 [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||
const realtime = liveRealtime ?? detail.realtimeSummary;
|
||||
@@ -180,10 +182,11 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
const mapVehicles = realtime ? [realtime] : [];
|
||||
const lastMileage = detail.mileage.items[0];
|
||||
return <div className="v2-vehicle-record-page">
|
||||
<MonitorReturnBar />
|
||||
<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-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>
|
||||
|
||||
<div className="v2-record-grid">
|
||||
@@ -201,6 +204,8 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
|
||||
export default function VehiclePage() {
|
||||
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 resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
|
||||
const realtime = useQuery({
|
||||
@@ -217,5 +222,5 @@ export default function VehiclePage() {
|
||||
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.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(); }} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user