perf(web): make mileage exports cancellable
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 工作簿生成失败'
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user