diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 5d93a51d..125e2986 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -29,6 +29,9 @@ export default function App() { const [historyFilters, setHistoryFilters] = useState>( initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {} ); + const [mileageFilters, setMileageFilters] = useState>( + initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {} + ); const [qualityFilters, setQualityFilters] = useState>( initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} ); @@ -110,6 +113,9 @@ export default function App() { if (route.page === 'history') { setHistoryFilters(historyFiltersFromRoute(route)); } + if (route.page === 'mileage') { + setMileageFilters(mileageFiltersFromRoute(route)); + } if (route.page === 'quality') { setQualityFilters(qualityFiltersFromRoute(route)); } @@ -138,7 +144,7 @@ export default function App() { replaceHistoryHash(historyFilters); return; } - replaceHash(page, analysisVin, activeProtocol); + replaceMileageHash(mileageFilters); return; } if (page === 'quality') { @@ -202,6 +208,21 @@ export default function App() { replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters); }; + const updateMileageFilters = (filters: Record = {}) => { + const nextFilters = normalizeMileageFilterValues(filters); + setMileageFilters(nextFilters); + if (nextFilters.keyword) { + setAnalysisVin(nextFilters.keyword); + } + setActiveProtocol(nextFilters.protocol ?? ''); + replaceMileageHash(nextFilters); + }; + + const replaceMileageHash = (filters: Record = {}) => { + const { keyword, protocol, ...restFilters } = filters; + replaceHash('mileage', keyword ?? analysisVin, protocol ?? activeProtocol, restFilters); + }; + const replaceQualityHash = (filters: Record = {}) => { const keyword = filters.keyword; const protocol = filters.protocol; @@ -275,6 +296,7 @@ export default function App() { } setAnalysisVin(nextVin); setActiveProtocol(nextProtocol); + setMileageFilters({ keyword: nextVin, protocol: nextProtocol }); setActivePage('mileage'); replaceHash('mileage', nextVin, nextProtocol); }; @@ -296,7 +318,7 @@ export default function App() { realtime: , detail: , history: , - mileage: , + mileage: , quality: setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} /> }; @@ -343,6 +365,26 @@ function historyFiltersFromRoute(route: ReturnType): Record }); } +function mileageFiltersFromRoute(route: ReturnType): Record { + return normalizeMileageFilterValues({ + ...(route.keyword ? { keyword: route.keyword } : {}), + ...(route.protocol ? { protocol: route.protocol } : {}), + ...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}), + ...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {}) + }); +} + +function normalizeMileageFilterValues(filters: Record = {}): Record { + const normalized: Record = {}; + for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo'] as const) { + const value = String(filters[key] ?? '').trim(); + if (value) { + normalized[key] = value; + } + } + return normalized; +} + function normalizeHistoryFilterValues(filters: Record = {}): Record { const normalized: Record = {}; for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) { diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index b8184c22..83e6c88e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -32,12 +32,32 @@ function canOpenVehicle(vin?: string) { return Boolean(value && value !== 'unknown'); } -export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initialVin: string; initialProtocol?: string; onOpenVehicle: (vin: string, protocol?: string) => void }) { +function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}) { + return { + keyword: initialVin, + protocol: initialProtocol ?? '', + ...initialFilters + }; +} + +export function Mileage({ + initialVin, + initialProtocol, + initialFilters = {}, + onFiltersChange, + onOpenVehicle +}: { + initialVin: string; + initialProtocol?: string; + initialFilters?: Record; + onFiltersChange?: (filters: Record) => void; + onOpenVehicle: (vin: string, protocol?: string) => void; +}) { const [rows, setRows] = useState([]); const [summary, setSummary] = useState(emptySummary); const [loading, setLoading] = useState(true); const [summaryLoading, setSummaryLoading] = useState(true); - const [filters, setFilters] = useState>({ keyword: initialVin, protocol: initialProtocol ?? '' }); + const [filters, setFilters] = useState>(mergeInitialFilters(initialVin, initialProtocol, initialFilters)); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; @@ -62,11 +82,11 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia }; useEffect(() => { - const nextFilters = { keyword: initialVin, protocol: initialProtocol ?? '' }; + const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); setFilters(nextFilters); loadSummary(nextFilters); load(nextFilters, 1, pagination.pageSize); - }, [initialVin, initialProtocol]); + }, [initialVin, initialProtocol, JSON.stringify(initialFilters)]); return (
@@ -85,9 +105,10 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia 里程统计按当前车辆与来源范围汇总。
-
{ + { const nextFilters = values as Record; setFilters(nextFilters); + onFiltersChange?.(nextFilters); loadSummary(nextFilters); load(nextFilters, 1, pagination.pageSize); }}> @@ -102,8 +123,9 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia 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 0ccb1ea3..0d0742db 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -1861,6 +1861,103 @@ test('shows vehicle and source scope on mileage hash', async () => { expect(screen.getByText('当前来源:GB32960')).toBeInTheDocument(); }); +test('applies mileage date range from shareable hash to API requests', async () => { + window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-002&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + 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; + } + if (path.includes('/api/mileage/summary')) { + return { + ok: true, + json: async () => ({ + data: { vehicleCount: 1, recordCount: 2, sourceCount: 1, totalMileageKm: 12.3, averageMileagePerVin: 12.3 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined); + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined); + expect(screen.getByText('当前车辆:VIN-MILEAGE-002')).toBeInTheDocument(); + expect(screen.getByText('当前来源:JT808')).toBeInTheDocument(); +}); + +test('updates mileage hash when mileage filters are submitted', async () => { + window.history.replaceState(null, '', '/#/mileage'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + 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; + } + if (path.includes('/api/mileage/summary')) { + return { + ok: true, + json: async () => ({ + data: { vehicleCount: 1, recordCount: 2, sourceCount: 1, totalMileageKm: 12.3, averageMileagePerVin: 12.3 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + await screen.findByRole('heading', { name: '里程统计' }); + fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤AG18312' } }); + fireEvent.click(screen.getByText('全部来源')); + fireEvent.click(await screen.findByText('JT808')); + fireEvent.change(screen.getByPlaceholderText('2026-07-01'), { target: { value: '2026-07-01' } }); + fireEvent.change(screen.getByPlaceholderText('2026-07-03'), { target: { value: '2026-07-03' } }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(window.location.hash).toBe('#/mileage?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03'); + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined); +}); + test('opens current vehicle service from mileage header with current source evidence', async () => { window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-HEADER&protocol=GB32960'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {