perf(mileage): stream workbook export rows
This commit is contained in:
@@ -6,11 +6,11 @@ import StatisticsPage from './StatisticsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../domain/mileageExport', () => exportMocks);
|
||||
vi.mock('../domain/mileageExport', () => ({ createMileageExportStream: exportMocks.createMileageExportStream }));
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); 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()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
|
||||
|
||||
function renderPage(initialEntry = '/statistics') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -18,6 +18,8 @@ function renderPage(initialEntry = '/statistics') {
|
||||
}
|
||||
|
||||
function prepareData() {
|
||||
exportMocks.finish.mockResolvedValue(undefined);
|
||||
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||
@@ -188,20 +190,30 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
||||
|
||||
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
|
||||
prepareData();
|
||||
exportMocks.downloadMileageWorkbook.mockResolvedValue(undefined);
|
||||
const exportRows = [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
|
||||
];
|
||||
mocks.dailyMileage.mockImplementation((params: URLSearchParams) => {
|
||||
if (params.get('vehicleScope') !== 'bound') return Promise.resolve({ items: exportRows, total: 2, limit: 10000, offset: 0 });
|
||||
const offset = Number(params.get('offset') ?? 0);
|
||||
return Promise.resolve({ items: exportRows.slice(offset, offset + 1), total: 2, limit: 1, offset });
|
||||
});
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
|
||||
|
||||
await waitFor(() => expect(exportMocks.downloadMileageWorkbook).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
|
||||
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
|
||||
expect(exportMileageCall.get('vins')).toBeNull();
|
||||
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
|
||||
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1);
|
||||
expect(exportMocks.appendRows).toHaveBeenCalledTimes(2);
|
||||
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
|
||||
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -227,7 +239,7 @@ test('shows export progress and lets the user cancel the active request', async
|
||||
|
||||
await waitFor(() => expect(exportSignal?.aborted).toBe(true));
|
||||
expect(await screen.findByText(/已取消导出/)).toBeInTheDocument();
|
||||
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
|
||||
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('aborts an active export when the mileage route unmounts', async () => {
|
||||
@@ -248,5 +260,5 @@ test('aborts an active export when the mileage route unmounts', async () => {
|
||||
view.unmount();
|
||||
|
||||
expect(exportSignal?.aborted).toBe(true);
|
||||
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
|
||||
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
import { downloadMileageWorkbook } from '../domain/mileageExport';
|
||||
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
@@ -347,6 +347,7 @@ export default function StatisticsPage() {
|
||||
};
|
||||
setIsExporting(true);
|
||||
setExportFeedback('');
|
||||
let exportStream: MileageExportStream | undefined;
|
||||
try {
|
||||
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
|
||||
if (!vehicles.length) {
|
||||
@@ -362,7 +363,14 @@ export default function StatisticsPage() {
|
||||
if (offset >= result.total) break;
|
||||
}
|
||||
}
|
||||
const mileageRows: DailyMileageRow[] = [];
|
||||
const plateByVin = new Map(vehicles.filter((vehicle) => vehicle.plate).map((vehicle) => [vehicle.vin, vehicle.plate]));
|
||||
exportStream = createMileageExportStream({
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
dates,
|
||||
sources: criteria.sources.filter((source) => source.enabled),
|
||||
exportedAt: new Date()
|
||||
}, controller.signal);
|
||||
const vehicleBatches = criteria.vehicles.length
|
||||
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
|
||||
: [[] as VehicleOption[]];
|
||||
@@ -375,24 +383,17 @@ export default function StatisticsPage() {
|
||||
while (true) {
|
||||
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
|
||||
const result = await api.dailyMileage(params, controller.signal);
|
||||
mileageRows.push(...result.items);
|
||||
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
|
||||
exportStream.appendRows(result.items);
|
||||
offset += result.items.length;
|
||||
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
|
||||
if (!result.items.length || offset >= result.total) break;
|
||||
}
|
||||
}
|
||||
const plateByVin = new Map(mileageRows.filter((row) => row.plate).map((row) => [row.vin, row.plate]));
|
||||
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
|
||||
reportProgress({ label: '正在后台生成 Excel 文件' });
|
||||
await downloadMileageWorkbook({
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
dates,
|
||||
vehicles,
|
||||
mileageRows,
|
||||
sources: criteria.sources.filter((source) => source.enabled),
|
||||
exportedAt: new Date()
|
||||
}, controller.signal);
|
||||
await exportStream.finish(vehicles);
|
||||
exportStream = undefined;
|
||||
if (mountedRef.current) setExportFeedback(`已导出 ${vehicles.length} 辆车`);
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
@@ -401,6 +402,7 @@ export default function StatisticsPage() {
|
||||
: error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
exportStream?.dispose();
|
||||
if (exportControllerRef.current === controller) exportControllerRef.current = null;
|
||||
if (mountedRef.current) {
|
||||
setIsExporting(false);
|
||||
|
||||
Reference in New Issue
Block a user