perf(statistics): mount one responsive mileage matrix

This commit is contained in:
lingniu
2026-07-16 03:47:00 +08:00
parent 2f26fd25b9
commit 5d3f5f1e59
2 changed files with 74 additions and 17 deletions

View File

@@ -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();

View File

@@ -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<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
@@ -197,6 +198,18 @@ function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics;
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; 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 <>
<div className="v2-mileage-table-wrap">
const mobile = useMobileMileageLayout();
if (mobile) return <div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-days">{dates.map((date) => <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong></div>)}</div></article>)}</div>;
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
return <div className="v2-mileage-table-wrap">
<table className="v2-mileage-table">
<thead><tr><th className="is-sticky is-plate"></th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total"></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
@@ -222,9 +239,7 @@ function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: st
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
</table>
</div>
<div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-days">{dates.map((date) => <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong></div>)}</div></article>)}</div>
</>;
</div>;
}
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<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
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<string, number>();
const sources = new Map<string, string>();
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<string, number>();
const sources = daily?.sources ?? new Map<string, string>();
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 }); };