diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index a66227f6..5d93a51d 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -26,6 +26,9 @@ export default function App() { const [realtimeFilters, setRealtimeFilters] = useState>( initialRoute.page === 'realtime' ? realtimeFiltersFromRoute(initialRoute) : {} ); + const [historyFilters, setHistoryFilters] = useState>( + initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {} + ); const [qualityFilters, setQualityFilters] = useState>( initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} ); @@ -104,6 +107,9 @@ export default function App() { if (route.page === 'realtime') { setRealtimeFilters(realtimeFiltersFromRoute(route)); } + if (route.page === 'history') { + setHistoryFilters(historyFiltersFromRoute(route)); + } if (route.page === 'quality') { setQualityFilters(qualityFiltersFromRoute(route)); } @@ -128,6 +134,10 @@ export default function App() { return; } if (page === 'history' || page === 'mileage') { + if (page === 'history') { + replaceHistoryHash(historyFilters); + return; + } replaceHash(page, analysisVin, activeProtocol); return; } @@ -172,6 +182,26 @@ export default function App() { replaceHash('realtime', keyword, protocol, restFilters); }; + const updateHistoryFilters = (filters: Record = {}, tab = historyTab) => { + const nextFilters = normalizeHistoryFilterValues(filters); + setHistoryFilters(nextFilters); + if (nextFilters.keyword) { + setAnalysisVin(nextFilters.keyword); + } + setActiveProtocol(nextFilters.protocol ?? ''); + setHistoryTab(tab); + replaceHistoryHash(nextFilters, tab); + }; + + const replaceHistoryHash = (filters: Record = {}, tab = historyTab) => { + const { keyword, protocol, ...restFilters } = filters; + const routeFilters = { ...restFilters }; + if (tab && tab !== 'location') { + routeFilters.tab = tab; + } + replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters); + }; + const replaceQualityHash = (filters: Record = {}) => { const keyword = filters.keyword; const protocol = filters.protocol; @@ -218,6 +248,7 @@ export default function App() { setAnalysisVin(nextVin); setActiveProtocol(nextProtocol); setHistoryTab('location'); + setHistoryFilters({ keyword: nextVin, protocol: nextProtocol }); setActivePage('history'); replaceHash('history', nextVin, nextProtocol); }; @@ -231,6 +262,7 @@ export default function App() { setAnalysisVin(nextVin); setActiveProtocol(nextProtocol); setHistoryTab('raw'); + setHistoryFilters({ keyword: nextVin, protocol: nextProtocol }); setActivePage('history'); replaceHash('history', nextVin, nextProtocol, { tab: 'raw' }); }; @@ -263,7 +295,7 @@ export default function App() { vehicles: , realtime: , detail: , - history: , + history: , mileage: , quality: setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} /> }; @@ -300,6 +332,32 @@ function realtimeFiltersFromRoute(route: ReturnType): Recor }; } +function historyFiltersFromRoute(route: ReturnType): Record { + return normalizeHistoryFilterValues({ + ...(route.keyword ? { keyword: route.keyword } : {}), + ...(route.protocol ? { protocol: route.protocol } : {}), + ...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}), + ...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {}), + ...(route.filters?.includeFields ? { includeFields: route.filters.includeFields } : {}), + ...(route.filters?.fields ? { fields: route.filters.fields } : {}) + }); +} + +function normalizeHistoryFilterValues(filters: Record = {}): Record { + const normalized: Record = {}; + for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) { + const value = String(filters[key] ?? '').trim(); + if (value) { + normalized[key] = value; + } + } + const includeFields = filters.includeFields; + if (includeFields === true || includeFields === 'true') { + normalized.includeFields = 'true'; + } + return normalized; +} + function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus { const sourceCount = overview.sourceCount; const onlineSourceCount = overview.onlineSourceCount; diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index 80993c3b..a4fa9179 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -9,7 +9,19 @@ export type AppRoute = { filters?: Record; }; -const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'missingProtocol', 'issueType', 'tab'] as const; +const filterKeys = [ + 'coverage', + 'serviceStatus', + 'online', + 'bindingStatus', + 'missingProtocol', + 'issueType', + 'tab', + 'dateFrom', + 'dateTo', + 'includeFields', + 'fields' +] as const; export function parseAppHash(hash: string): AppRoute { const normalized = hash.trim().replace(/^#\/?/, ''); diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 6371fea3..288de426 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -11,7 +11,7 @@ type HistoryFilters = { dateFrom?: string; dateTo?: string; fields?: string; - includeFields?: boolean; + includeFields?: boolean | string; }; const defaultFilters: HistoryFilters = { @@ -39,19 +39,37 @@ function normalizeHistoryTab(value?: string): HistoryTabKey { return value === 'raw' ? 'raw' : 'location'; } +function isIncludeFieldsEnabled(value?: boolean | string) { + return value === true || value === 'true'; +} + +function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}): HistoryFilters { + return { + ...defaultFilters, + keyword: initialVin || defaultFilters.keyword, + protocol: initialProtocol, + ...initialFilters, + includeFields: isIncludeFieldsEnabled(initialFilters.includeFields) + }; +} + export function History({ initialVin, initialProtocol, initialTab, + initialFilters = {}, + onFiltersChange, onOpenVehicle }: { initialVin: string; initialProtocol?: string; initialTab?: string; + initialFilters?: Record; + onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void; onOpenVehicle: (vin: string, protocol?: string) => void; }) { - const initialFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol }; - const [filters, setFilters] = useState(initialFilters); + const initialHistoryFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); + const [filters, setFilters] = useState(initialHistoryFilters); const [activeTab, setActiveTab] = useState(normalizeHistoryTab(initialTab)); const [locations, setLocations] = useState>(defaultPage); const [rawFrames, setRawFrames] = useState>(defaultPage); @@ -67,7 +85,7 @@ export function History({ if (nextFilters.protocol?.trim()) params.set('protocol', nextFilters.protocol.trim()); if (nextFilters.dateFrom?.trim()) params.set('dateFrom', nextFilters.dateFrom.trim()); if (nextFilters.dateTo?.trim()) params.set('dateTo', nextFilters.dateTo.trim()); - if (raw && nextFilters.includeFields) params.set('includeFields', 'true'); + if (raw && isIncludeFieldsEnabled(nextFilters.includeFields)) params.set('includeFields', 'true'); if (raw && nextFilters.fields?.trim()) params.set('fields', nextFilters.fields.trim()); return params; }; @@ -78,7 +96,7 @@ export function History({ if (nextFilters.protocol?.trim()) query.protocol = nextFilters.protocol.trim(); if (nextFilters.dateFrom?.trim()) query.dateFrom = nextFilters.dateFrom.trim(); if (nextFilters.dateTo?.trim()) query.dateTo = nextFilters.dateTo.trim(); - if (nextFilters.includeFields) query.includeFields = true; + if (isIncludeFieldsEnabled(nextFilters.includeFields)) query.includeFields = true; const fields = splitFields(nextFilters.fields); if (fields.length > 0) query.fields = fields; return query; @@ -116,24 +134,32 @@ export function History({ includeFields: Boolean(values.includeFields) }; setFilters(nextFilters); + onFiltersChange?.(nextFilters, activeTab); loadLocations(nextFilters, 1, locationPagination.pageSize); loadRawFrames(nextFilters, 1, rawPagination.pageSize); }; const reset = () => { - const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol }; + const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {}); setFilters(nextFilters); + onFiltersChange?.(nextFilters, activeTab); loadLocations(nextFilters, 1, locationPagination.pageSize); loadRawFrames(nextFilters, 1, rawPagination.pageSize); }; useEffect(() => { - const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol }; + const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); setFilters(nextFilters); setActiveTab(normalizeHistoryTab(initialTab)); loadLocations(nextFilters, 1, locationPagination.pageSize); loadRawFrames(nextFilters, 1, rawPagination.pageSize); - }, [initialVin, initialProtocol, initialTab]); + }, [initialVin, initialProtocol, initialTab, JSON.stringify(initialFilters)]); + + const changeTab = (key: string) => { + const nextTab = normalizeHistoryTab(String(key)); + setActiveTab(nextTab); + onFiltersChange?.(filters, nextTab); + }; const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); const currentVehicleKeyword = filters.keyword?.trim() ?? ''; @@ -156,7 +182,7 @@ export function History({ 历史位置和 RAW 帧按当前车辆与来源范围查询。 -
submit(values)}> + submit(values)}> GB32960 @@ -182,7 +208,7 @@ export function History({
- setActiveTab(normalizeHistoryTab(String(key)))}> + changeTab(String(key))}> expect(screen.getByText('当前来源:JT808')).toBeInTheDocument(); }); +test('updates history hash when vehicle history filters are submitted', async () => { + window.history.replaceState(null, '', '/#/history'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + 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 / 车牌 / 手机号'), { target: { value: '粤AG18312' } }); + fireEvent.click(screen.getByText('全部来源')); + fireEvent.click(await screen.findByText('JT808')); + fireEvent.change(screen.getByPlaceholderText('2026-07-03 00:00:00'), { target: { value: '2026-07-01 00:00:00' } }); + fireEvent.change(screen.getByPlaceholderText('2026-07-03 23:59:59'), { target: { value: '2026-07-01 23:59:59' } }); + fireEvent.click(screen.getByRole('button', { name: 'search 查询' })); + + await waitFor(() => { + expect(window.location.hash).toBe('#/history?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01+00%3A00%3A00&dateTo=2026-07-01+23%3A59%3A59'); + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01+00%3A00%3A00'), undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"dateFrom":"2026-07-01 00:00:00"') + })); +}); + test('shows vehicle and source scope on mileage hash', async () => { window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-001&protocol=GB32960'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {