diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index c12f0809..a66227f6 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -23,6 +23,9 @@ export default function App() { const [vehicleFilters, setVehicleFilters] = useState>( initialRoute.page === 'vehicles' ? vehicleFiltersFromRoute(initialRoute) : {} ); + const [realtimeFilters, setRealtimeFilters] = useState>( + initialRoute.page === 'realtime' ? realtimeFiltersFromRoute(initialRoute) : {} + ); const [qualityFilters, setQualityFilters] = useState>( initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} ); @@ -98,6 +101,9 @@ export default function App() { if (route.page === 'vehicles') { setVehicleFilters(vehicleFiltersFromRoute(route)); } + if (route.page === 'realtime') { + setRealtimeFilters(realtimeFiltersFromRoute(route)); + } if (route.page === 'quality') { setQualityFilters(qualityFiltersFromRoute(route)); } @@ -133,6 +139,10 @@ export default function App() { replaceVehicleHash(vehicleFilters); return; } + if (page === 'realtime') { + replaceRealtimeHash(realtimeFilters); + return; + } replaceHash(page); }; @@ -152,6 +162,16 @@ export default function App() { replaceHash('vehicles', keyword, protocol, restFilters); }; + const updateRealtimeFilters = (filters: Record = {}) => { + setRealtimeFilters(filters); + replaceRealtimeHash(filters); + }; + + const replaceRealtimeHash = (filters: Record = {}) => { + const { keyword, protocol, ...restFilters } = filters; + replaceHash('realtime', keyword, protocol, restFilters); + }; + const replaceQualityHash = (filters: Record = {}) => { const keyword = filters.keyword; const protocol = filters.protocol; @@ -241,7 +261,7 @@ export default function App() { const pages: Record = { dashboard: navigatePage('quality')} onOpenVehicles={openVehicles} />, vehicles: , - realtime: , + realtime: , detail: , history: , mileage: , @@ -271,6 +291,15 @@ function vehicleFiltersFromRoute(route: ReturnType): Record }; } +function realtimeFiltersFromRoute(route: ReturnType): Record { + return { + ...(route.keyword ? { keyword: route.keyword } : {}), + ...(route.protocol ? { protocol: route.protocol } : {}), + ...(route.filters?.online ? { online: route.filters.online } : {}), + ...(route.filters?.serviceStatus ? { serviceStatus: route.filters.serviceStatus } : {}) + }; +} + function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus { const sourceCount = overview.sourceCount; const onlineSourceCount = overview.onlineSourceCount; diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index 88e6448c..c82fefa5 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -31,10 +31,18 @@ function sourceEvidenceText(row: VehicleRealtimeRow) { return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`; } -export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, protocol?: string) => void }) { +export function Realtime({ + onOpenVehicle, + onFiltersChange, + initialFilters = {} +}: { + onOpenVehicle: (vin: string, protocol?: string) => void; + onFiltersChange?: (filters: Record) => void; + initialFilters?: Record; +}) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 }); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { @@ -54,17 +62,23 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto }; useEffect(() => { - load({}, 1, pagination.pageSize); - }, []); + setFilters(initialFilters); + load(initialFilters, 1, pagination.pageSize); + }, [JSON.stringify(initialFilters)]); + + const applyFilters = (nextFilters: Record) => { + setFilters(nextFilters); + onFiltersChange?.(nextFilters); + load(nextFilters, 1, pagination.pageSize); + }; return (
-
{ + { const nextFilters = values as Record; - setFilters(nextFilters); - load(nextFilters, 1, pagination.pageSize); + applyFilters(nextFilters); }} style={{ marginBottom: 12 }}> @@ -85,8 +99,7 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 6e0cfdf1..31e9731c 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2393,6 +2393,96 @@ test('frames realtime page as one vehicle service with source evidence', async ( expect(screen.getByText('2/2 来源在线')).toBeInTheDocument(); }); +test('loads realtime vehicles from shareable source filter hash', async () => { + window.history.replaceState(null, '', '/#/realtime?keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/realtime/vehicles')) { + return { + ok: true, + json: async () => ({ + data: { + items: [{ + vin: 'VIN-RT-FILTERED', + plate: '粤ART002', + phone: '', + oem: 'G7s', + protocols: ['JT808'], + sourceCount: 1, + onlineSourceCount: 1, + online: true, + primaryProtocol: 'JT808', + longitude: 113.2, + latitude: 23.1, + speedKmh: 30, + socPercent: 78, + totalMileageKm: 119925, + lastSeen: '2026-07-03 20:12:10', + bindingStatus: 'bound' + }], + total: 1, + limit: 50, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('VIN-RT-FILTERED')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), undefined); +}); + +test('updates realtime hash when source filters are submitted', async () => { + window.history.replaceState(null, '', '/#/realtime'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/realtime/vehicles')) { + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 50, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + await screen.findByRole('heading', { name: '车辆服务实时态' }); + fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤ART002' } }); + fireEvent.click(screen.getByText('全部来源')); + fireEvent.click(await screen.findByText('JT808')); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(window.location.hash).toBe('#/realtime?keyword=%E7%B2%A4ART002&protocol=JT808'); + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808'), undefined); +}); + test('shows vehicle service status in vehicle list', async () => { window.history.replaceState(null, '', '/#/vehicles'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {