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 工作簿生成失败'
});
}
};