From 1cfc9c2bdd6c20c0751c016049b87f720c8c44e0 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 17:08:54 +0800 Subject: [PATCH] feat(monitor): preserve investigation context --- .../apps/web/src/v2/map/FleetMap.test.tsx | 23 ++++ .../apps/web/src/v2/map/FleetMap.tsx | 26 ++-- .../web/src/v2/pages/HistoryPage.test.tsx | 25 +++- .../apps/web/src/v2/pages/HistoryPage.tsx | 8 +- .../web/src/v2/pages/MonitorPage.test.tsx | 28 ++++- .../apps/web/src/v2/pages/MonitorPage.tsx | 44 ++++--- .../web/src/v2/pages/StatisticsPage.test.tsx | 15 +++ .../apps/web/src/v2/pages/StatisticsPage.tsx | 8 +- .../apps/web/src/v2/pages/TrackPage.test.tsx | 17 +++ .../apps/web/src/v2/pages/TrackPage.tsx | 8 +- .../web/src/v2/pages/VehiclePage.test.tsx | 21 ++++ .../apps/web/src/v2/pages/VehiclePage.tsx | 13 +- .../web/src/v2/routing/monitorContext.test.ts | 57 +++++++++ .../apps/web/src/v2/routing/monitorContext.ts | 116 ++++++++++++++++++ .../src/v2/shared/MonitorReturnBar.test.tsx | 19 +++ .../web/src/v2/shared/MonitorReturnBar.tsx | 21 ++++ .../apps/web/src/v2/styles/v2.css | 14 ++- 17 files changed, 426 insertions(+), 37 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/routing/monitorContext.test.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.tsx diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index b60dd21d..07a61fab 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -296,6 +296,29 @@ test('recovers in place after a transient AMap failure and renders data that arr 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( 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 () => { window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; window.AMapLoader = { load: vi.fn(async () => amapMock()) }; diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 6e1ff3e7..11e83ef1 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -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) { const status = vehicleStatus(vehicle); return statusStyleIndex(status); @@ -270,13 +278,14 @@ function densePlatePlacements(points: PlateLabelPoint[]) { return placements; } -export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange }: { +export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange, initialViewport }: { vehicles: VehicleRealtimeRow[]; selectedVin?: string; onSelect: (vehicle: VehicleRealtimeRow) => void; monitorMap?: MonitorMapResponse; onSelectVin?: (vin: string) => void; onViewportChange?: (viewport: MonitorViewport) => void; + initialViewport?: MonitorViewport; }) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -302,7 +311,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const viewportTimerRef = useRef(undefined); const selectionKeyRef = useRef(''); const selectionPositionRef = useRef(''); - const centeredVinRef = useRef(''); + const initialViewportRef = useRef(initialViewport); + const centeredVinRef = useRef(initialViewport?.bounds ? selectedVin ?? '' : ''); const followSelectedRef = useRef(true); const programmaticFollowRef = useRef(false); const selectedVinRef = useRef(selectedVin); @@ -310,7 +320,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const [loadAttempt, setLoadAttempt] = useState(0); const [showLabels, setShowLabels] = 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 monitorMode = monitorMap?.mode; const monitorPoints = monitorMap?.points; @@ -354,11 +364,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { if (cancelled || !containerRef.current) return; const initialSelection = initialSelectionRef.current; - const initialCenter = initialSelection + const restoredCenter = viewportCenter(initialViewportRef.current); + const initialCenter = restoredCenter ?? (initialSelection ? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude) - : wgs84ToGcj02(105.4, 35.9); + : wgs84ToGcj02(105.4, 35.9)); const map = new AMap.Map(containerRef.current, { - zoom: initialSelection ? 13 : 5, + zoom: initialViewportRef.current?.zoom ?? (initialSelection ? 13 : 5), center: initialCenter, viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', @@ -648,7 +659,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect }, [fallbackPoints, mapZoom, monitorPoints, selectedTarget, selectedVin, showLabels, state]); useEffect(() => { - if (!selectedVin || !mapRef.current || !amapRef.current) { + if (!selectedVin) { selectionRef.current?.setMap?.(null); selectionRef.current = null; selectionKeyRef.current = ''; @@ -658,6 +669,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect setFollowSelected(false); return; } + if (!mapRef.current || !amapRef.current) return; const target = selectedTarget; if (!target) return; const label = escapeHtml(target.plate || target.vin); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index cf8b3613..b8e8bb7f 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -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(); + const view = render(); 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; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index dd2b8b89..a2591515 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -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
+
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index cecd3a1c..1c0a3515 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -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
+ FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => { + fleetMapRenderSpy(selectedVin, initialViewport); + return
; } @@ -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(); + + 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 }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 9238cea2..ec29714c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -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({ {vehicle.vin}
- 单车详情 - 轨迹回放 - 历史数据 + 单车详情 + 轨迹回放 + 历史数据 + 里程查询

车辆信息

@@ -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({ 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(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 ? ( diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx index c5607ac9..856d98a9 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import StatisticsPage from './StatisticsPage'; +import { 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'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index c9294dcc..95951458 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -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(() => initialCriteria(searchParams)); const [criteria, setCriteria] = useState(() => 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
+
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index 8dd26b84..d981144c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { MemoryRouter, 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 <>; } +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(); + + 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(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 79844f92..218a2ed9 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -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(() => { @@ -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
+
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 717e28c8..afc37895 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -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(} />); + + 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); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index 1e298723..79f1bcdf 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -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
; } -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
+
{fmt(identity?.plate || realtime?.plate)}{realtime?.online ? '在线' : '离线'}VIN{detail.vin}
接入来源

{detail.sources.map((source) => {source})}

最后上报时间{fmt(realtime?.lastSeen || identity?.lastSeen)}
-
轨迹回放历史数据告警事件
+
轨迹回放历史数据告警事件
@@ -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 ; if (query.isError) return
query.refetch()} />
; if (!query.data.lookupResolved) return

未找到车辆

没有匹配“{vin}”的车牌、VIN 或终端记录。

重新查询
; - return { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />; + return { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />; } diff --git a/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.test.ts b/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.test.ts new file mode 100644 index 00000000..f1303658 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.test.ts @@ -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); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts b/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts new file mode 100644 index 00000000..f53de45f --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/routing/monitorContext.ts @@ -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) { + 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; +} diff --git a/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.test.tsx new file mode 100644 index 00000000..9906e9ef --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.test.tsx @@ -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(); + + expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', expect.stringContaining('/monitor?')); + expect(screen.getByText(/LTEST000000000001/)).toBeInTheDocument(); + expect(screen.getByText(/Zoom 13 · 详情收起/)).toBeInTheDocument(); + + view.unmount(); + render(); + expect(screen.queryByRole('link', { name: /返回全局监控/ })).not.toBeInTheDocument(); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.tsx b/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.tsx new file mode 100644 index 00000000..ebff480d --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/MonitorReturnBar.tsx @@ -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
+ 返回全局监控 + {scope || '恢复离开前的地图与筛选状态'} + Zoom {context.viewport.zoom} · {context.detailOpen ? '详情展开' : context.selectedVin ? '详情收起' : '未选车辆'} +
; +} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 72319331..37811b9c 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -18,6 +18,12 @@ body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family: button, input, select { font: inherit; } 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-intro { max-width: 640px; padding: 36px 8vw 36px 4vw; } .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. */ -.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-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%); } @@ -2065,6 +2072,11 @@ button, a { -webkit-tap-highlight-color: transparent; } @media (max-width: 700px) { .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-button { font-size: 13px; } .v2-alert-tabs { overflow-x: auto; }