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 6bc7c1de..4b9a63a2 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 @@ -10,7 +10,7 @@ const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() })); vi.mock('../../api/client', () => ({ api: mocks })); vi.mock('../domain/mileageExport', () => exportMocks); -afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.mockReset(); }); +afterEach(() => { cleanup(); vi.restoreAllMocks(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.mockReset(); }); function renderPage(initialEntry = '/statistics') { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -32,9 +32,9 @@ function prepareData() { mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 }); } -test('renders one vehicle per row with dates as columns and a period total', async () => { +test('renders only the desktop matrix with dates as columns and a period total', async () => { prepareData(); - renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); expect(await screen.findByText('车辆每日里程')).toBeInTheDocument(); expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1); expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0); @@ -49,11 +49,39 @@ test('renders one vehicle per row with dates as columns and a period total', asy expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument(); expect(screen.getByText('已绑定主车辆')).toBeInTheDocument(); expect(screen.getByText('已选择 1 辆')).toBeInTheDocument(); + expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument(); + expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument(); await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1)); expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000'); expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT'); }); +test('mounts only the mobile mileage cards and removes the viewport listener', async () => { + prepareData(); + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + media: '(max-width: 680px)', + onchange: null, + addEventListener, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => false) + } as unknown as MediaQueryList); + const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + + expect(await screen.findByText('车辆每日里程')).toBeInTheDocument(); + await waitFor(() => expect(view.container.querySelectorAll('.v2-mileage-mobile-list article')).toHaveLength(1)); + expect(view.container.querySelector('.v2-mileage-table-wrap')).not.toBeInTheDocument(); + expect(view.container.querySelectorAll('.v2-mileage-mobile-days > div')).toHaveLength(2); + expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + + view.unmount(); + expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); +}); + test('supports searching and selecting license plates before querying exact VINs', async () => { prepareData(); renderPage(); 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 5d9ddada..a6deb869 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -14,6 +14,7 @@ const MAX_SELECTED_VEHICLES = 20; const PAGE_SIZE = 20; const EXPORT_VEHICLE_PAGE_SIZE = 2_000; const EXPORT_VIN_BATCH_SIZE = 50; +const MOBILE_MILEAGE_QUERY = '(max-width: 680px)'; type VehicleOption = Pick; type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT'; @@ -197,6 +198,18 @@ function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics; type VehicleMileageMatrix = VehicleOption & { days: Map; sources: Map; totalMileageKm: number }; +function useMobileMileageLayout() { + const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_MILEAGE_QUERY).matches); + useEffect(() => { + const media = window.matchMedia(MOBILE_MILEAGE_QUERY); + const update = () => setMobile(media.matches); + media.addEventListener('change', update); + update(); + return () => media.removeEventListener('change', update); + }, []); + return mobile; +} + function rangeDates(dateFrom: string, dateTo: string) { const dates: string[] = []; const cursor = new Date(`${dateFrom}T00:00:00`); @@ -211,9 +224,13 @@ function dateLabel(date: string) { } function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) { - const maxDailyMileage = Math.max(1, ...rows.flatMap((row) => Array.from(row.days.values()))); - return <> -
+ const mobile = useMobileMileageLayout(); + if (mobile) return
{rows.map((row) =>
{row.plate || '未绑定'}{row.vin}
{formatKm(row.totalMileageKm)} km区间总里程
{dates.map((date) =>
{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}
)}
)}
; + let maxDailyMileage = 1; + for (const row of rows) { + for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage); + } + return
{dates.map((date) => )}{rows.map((row) => {dates.map((date) => { @@ -222,9 +239,7 @@ function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: st return ; })})}
车牌VIN{dateLabel(date)}区间总里程
{row.plate || '未绑定'}{row.vin}{mileage != null ? `${formatKm(mileage)} km` : '—'}{formatKm(row.totalMileageKm)} km
-
-
{rows.map((row) =>
{row.plate || '未绑定'}{row.vin}
{formatKm(row.totalMileageKm)} km区间总里程
{dates.map((date) =>
{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}
)}
)}
- ; +
; } export default function StatisticsPage() { @@ -261,16 +276,30 @@ export default function StatisticsPage() { const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]); const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime, placeholderData: (previous) => previous }); const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: (previous) => previous }); - const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]); const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]); + const mileageByVin = useMemo(() => { + const index = new Map; sources: Map }>(); + for (const row of mileage.data?.items ?? []) { + let entry = index.get(row.vin); + if (!entry) { + entry = { plate: row.plate || '', days: new Map(), sources: new Map() }; + index.set(row.vin, entry); + } else if (!entry.plate && row.plate) entry.plate = row.plate; + entry.days.set(row.date, row.dailyMileageKm); + entry.sources.set(row.date, row.source); + } + return index; + }, [mileage.data?.items]); + const rankingByVin = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row])), [statistics.data?.ranking]); const matrixRows = useMemo(() => displayVehicles.map((vehicle) => { - const days = new Map(); - const sources = new Map(); - const dailyRows = (mileage.data?.items ?? []).filter((row) => row.vin === vehicle.vin); - for (const row of dailyRows) { days.set(row.date, row.dailyMileageKm); sources.set(row.date, row.source); } - const plate = vehicle.plate || dailyRows.find((row) => row.plate)?.plate || statistics.data?.ranking.find((row) => row.vin === vehicle.vin)?.plate || ''; - return { ...vehicle, plate, days, sources, totalMileageKm: totals.get(vehicle.vin) ?? Array.from(days.values()).reduce((sum, value) => sum + value, 0) }; - }), [displayVehicles, mileage.data?.items, statistics.data?.ranking, totals]); + const daily = mileageByVin.get(vehicle.vin); + const ranking = rankingByVin.get(vehicle.vin); + const days = daily?.days ?? new Map(); + const sources = daily?.sources ?? new Map(); + let dailyTotal = 0; + for (const value of days.values()) dailyTotal += value; + return { ...vehicle, plate: vehicle.plate || daily?.plate || ranking?.plate || '', days, sources, totalMileageKm: ranking?.mileageKm ?? dailyTotal }; + }), [displayVehicles, mileageByVin, rankingByVin]); const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE)); const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };