perf(statistics): mount one responsive mileage matrix
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 }); };
|
||||
|
||||
Reference in New Issue
Block a user