perf(web): make mileage exports cancellable

This commit is contained in:
lingniu
2026-07-16 01:43:57 +08:00
parent 639564b96d
commit c834a0406c
8 changed files with 246 additions and 15 deletions

View File

@@ -1,5 +1,7 @@
import { expect, test } from 'vitest';
import { createMileageWorkbook } from './mileageExport';
import { afterEach, expect, test, vi } from 'vitest';
import { createMileageWorkbook, downloadMileageWorkbook } from './mileageExport';
afterEach(() => vi.unstubAllGlobals());
test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => {
const workbook = await createMileageWorkbook({
@@ -36,3 +38,42 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});
test('does not build or download a workbook after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await expect(downloadMileageWorkbook({
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [],
mileageRows: [],
sources: []
}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' });
});
test('terminates the workbook worker when an active export is cancelled', async () => {
const terminate = vi.fn();
const postMessage = vi.fn();
class WorkerMock {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postMessage = postMessage;
terminate = terminate;
}
vi.stubGlobal('Worker', WorkerMock);
const controller = new AbortController();
const exportPromise = downloadMileageWorkbook({
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [],
mileageRows: [],
sources: []
}, controller.signal);
expect(postMessage).toHaveBeenCalledTimes(1);
controller.abort();
await expect(exportPromise).rejects.toMatchObject({ name: 'AbortError' });
expect(terminate).toHaveBeenCalledTimes(1);
});

View File

@@ -13,6 +13,16 @@ export type MileageExportInput = {
exportedAt?: Date;
};
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
function abortError() {
return new DOMException('里程导出已取消', 'AbortError');
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
function excelColumn(index: number) {
let value = index;
let result = '';
@@ -146,9 +156,46 @@ export async function createMileageWorkbook(input: MileageExportInput) {
return workbook;
}
export async function downloadMileageWorkbook(input: MileageExportInput) {
const workbook = await createMileageWorkbook(input);
const buffer = await workbook.xlsx.writeBuffer();
async function createMileageWorkbookBuffer(input: MileageExportInput, signal?: AbortSignal) {
throwIfAborted(signal);
if (typeof Worker === 'undefined') {
const workbook = await createMileageWorkbook(input);
throwIfAborted(signal);
const output = await workbook.xlsx.writeBuffer();
throwIfAborted(signal);
return new Uint8Array(output as ArrayBuffer).slice().buffer;
}
return new Promise<ArrayBuffer>((resolve, reject) => {
const worker = new Worker(new URL('./mileageExport.worker.ts', import.meta.url), { type: 'module' });
let settled = false;
const cleanup = () => {
signal?.removeEventListener('abort', cancel);
worker.terminate();
};
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
callback();
};
const cancel = () => finish(() => reject(abortError()));
worker.onmessage = (event: MessageEvent<MileageWorkerResponse>) => {
finish(() => event.data.ok ? resolve(event.data.buffer) : reject(new Error(event.data.message)));
};
worker.onerror = (event) => finish(() => reject(new Error(event.message || 'Excel 工作线程执行失败')));
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted) {
cancel();
return;
}
worker.postMessage(input);
});
}
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
const buffer = await createMileageWorkbookBuffer(input, signal);
throwIfAborted(signal);
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');

View File

@@ -0,0 +1,23 @@
import { createMileageWorkbook, type MileageExportInput } from './mileageExport';
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
type WorkerScope = {
onmessage: ((event: MessageEvent<MileageExportInput>) => void) | null;
postMessage: (message: MileageWorkerResponse, transfer?: Transferable[]) => void;
};
const workerScope = self as unknown as WorkerScope;
workerScope.onmessage = async (event) => {
try {
const workbook = await createMileageWorkbook(event.data);
const output = await workbook.xlsx.writeBuffer();
const buffer = new Uint8Array(output as ArrayBuffer).slice().buffer;
workerScope.postMessage({ ok: true, buffer }, [buffer]);
} catch (error) {
workerScope.postMessage({
ok: false,
message: error instanceof Error ? error.message : 'Excel 工作簿生成失败'
});
}
};

View File

@@ -126,5 +126,54 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
expect(exportMileageCall.get('vins')).toBeNull();
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).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(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
});
test('shows export progress and lets the user cancel the active request', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
exportSignal = signal;
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true });
});
});
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' }));
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '取消导出' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '取消导出' }));
await waitFor(() => expect(exportSignal?.aborted).toBe(true));
expect(await screen.findByText(/已取消导出/)).toBeInTheDocument();
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
});
test('aborts an active export when the mileage route unmounts', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
exportSignal = signal;
return new Promise(() => undefined);
});
const view = 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' }));
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
view.unmount();
expect(exportSignal?.aborted).toBe(true);
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
});

View File

@@ -19,6 +19,7 @@ type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
type ExportProgress = { label: string; completed?: number; total?: number };
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
const DEFAULT_SOURCES: MileageSourceOption[] = [
@@ -233,6 +234,16 @@ export default function StatisticsPage() {
const [page, setPage] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const [exportFeedback, setExportFeedback] = useState('');
const [exportProgress, setExportProgress] = useState<ExportProgress>();
const exportControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
exportControllerRef.current?.abort();
};
}, []);
const hasVehicles = criteria.vehicles.length > 0;
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({
@@ -265,9 +276,17 @@ export default function StatisticsPage() {
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const exportPercent = exportProgress?.total
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
: undefined;
const exportExcel = async () => {
if (isExporting || !totalVehicles) return;
if (exportControllerRef.current || !totalVehicles) return;
const controller = new AbortController();
exportControllerRef.current = controller;
const reportProgress = (progress: ExportProgress) => {
if (mountedRef.current && !controller.signal.aborted) setExportProgress(progress);
};
setIsExporting(true);
setExportFeedback('');
try {
@@ -275,9 +294,11 @@ export default function StatisticsPage() {
if (!vehicles.length) {
vehicles = [];
let offset = 0;
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }));
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
if (!result.items.length) break;
offset += result.items.length;
if (offset >= result.total) break;
@@ -287,18 +308,24 @@ export default function StatisticsPage() {
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[]];
for (const vehicleBatch of vehicleBatches) {
for (const [batchIndex, vehicleBatch] of vehicleBatches.entries()) {
let offset = 0;
const mileageLabel = vehicleBatches.length > 1
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length} 批)`
: '正在读取全部车辆里程';
reportProgress({ label: mileageLabel });
while (true) {
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(params);
const result = await api.dailyMileage(params, controller.signal);
mileageRows.push(...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,
@@ -307,15 +334,29 @@ export default function StatisticsPage() {
mileageRows,
sources: criteria.sources.filter((source) => source.enabled),
exportedAt: new Date()
});
setExportFeedback(`已导出 ${vehicles.length} 辆车`);
}, controller.signal);
if (mountedRef.current) setExportFeedback(`已导出 ${vehicles.length} 辆车`);
} catch (error) {
setExportFeedback(error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
if (mountedRef.current) {
setExportFeedback(controller.signal.aborted
? '已取消导出'
: error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
}
} finally {
setIsExporting(false);
if (exportControllerRef.current === controller) exportControllerRef.current = null;
if (mountedRef.current) {
setIsExporting(false);
setExportProgress(undefined);
}
}
};
const cancelExport = () => {
if (!exportControllerRef.current || exportControllerRef.current.signal.aborted) return;
setExportProgress({ label: '正在取消导出' });
exportControllerRef.current.abort();
};
return <div className="v2-mileage-page">
<header className="v2-mileage-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
<section className="v2-mileage-query-panel">
@@ -331,7 +372,11 @@ export default function StatisticsPage() {
</section>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<section className="v2-mileage-results">
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" onClick={exportExcel} disabled={isExporting || !totalVehicles}><IconDownload />{isExporting ? '正在导出' : '导出 Excel'}</button></div></header>
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
</div> : null}
<MileageTable rows={matrixRows} dates={dates} />
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null}
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer>

View File

@@ -1018,6 +1018,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-mileage-result-actions > button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 11px; color: #315f9f; cursor: pointer; font-size: 10px; font-weight: 650; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; }
.v2-mileage-result-actions > button:hover { border-color: #99b6dd; background: #f5f9ff; box-shadow: 0 2px 7px rgba(37,99,235,.08); }
.v2-mileage-result-actions > button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.v2-mileage-export-progress { display: flex; flex: 0 0 auto; align-items: center; gap: 12px; border-bottom: 1px solid #e4e9f0; background: #f7faff; padding: 9px 18px; }
.v2-mileage-export-progress > span { display: flex; min-width: 190px; align-items: center; justify-content: space-between; gap: 12px; color: #315f9f; font-size: 10px; }
.v2-mileage-export-progress > span strong { font-weight: 650; }.v2-mileage-export-progress > span small { color: #6f8198; font-variant-numeric: tabular-nums; }
.v2-mileage-export-progress > i { position: relative; height: 5px; flex: 1; overflow: hidden; border-radius: 999px; background: #dce8f8; }
.v2-mileage-export-progress > i b { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--v2-blue); transition: width .18s ease; }
.v2-mileage-export-progress > i.is-indeterminate b { width: 38%; animation: v2-export-progress 1.1s ease-in-out infinite; }
@keyframes v2-export-progress { 0% { transform: translateX(-110%); } 100% { transform: translateX(290%); } }
.v2-mileage-table-wrap { min-height: 0; flex: 1; overflow: auto; }
.v2-mileage-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.v2-mileage-table th { position: sticky; z-index: 2; top: 0; height: 42px; border-bottom: 1px solid #dfe6ef; background: #f8fafc; padding: 0 14px; color: #59677b; font-size: 11px; font-weight: 650; text-align: left; white-space: nowrap; }
@@ -1257,6 +1264,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-mileage-results > header div { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-mileage-results > header .v2-mileage-result-actions { width: 100%; align-items: center; justify-content: space-between; flex-direction: row; gap: 8px; }
.v2-mileage-result-actions > button { height: 34px; flex: 0 0 auto; }
.v2-mileage-export-progress { align-items: stretch; flex-direction: column; gap: 7px; padding: 9px 12px; }
.v2-mileage-export-progress > span { min-width: 0; }
.v2-mileage-table-wrap { display: none; }
.v2-mileage-mobile-list { display: flex; flex-direction: column; }
.v2-mileage-mobile-list article { border-bottom: 1px solid #e9eef5; padding: 14px 12px; }

View File

@@ -5,6 +5,9 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://115.29.187.2
export default defineConfig({
plugins: [react()],
worker: {
format: 'es'
},
server: {
proxy: {
'/api': apiProxyTarget,