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,

View File

@@ -2,6 +2,20 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: cancellable, off-main-thread mileage export
The full-fleet mileage export previously performed three long-running phases as one opaque foreground action: paginating vehicle identities, paginating daily mileage rows and building the ExcelJS workbook on the browser main thread. Navigating away did not cancel the export requests, and a large `writeBuffer()` could make controls and route transitions unresponsive.
The export now owns one `AbortController` for its complete lifecycle. Every vehicle and mileage request consumes that signal, leaving the route aborts the controller, and the action can be cancelled from the same button. The UI reports determinate progress while the response total is known and an indeterminate stage while Excel is generated. Workbook creation and serialization run in an ES-module Web Worker; the worker transfers the resulting `ArrayBuffer` back without copying and is terminated on completion, error or cancellation. ExcelJS remains action-only and does not enter the initial route bundle; a main-thread fallback is retained only for browsers without Worker support.
This matches the long-task feedback and isolation guidance used by mature design systems: Carbon recommends progress feedback for operations that last more than a moment, while Material UI explicitly recommends Web Workers or batching for processor-intensive work:
- <https://carbondesignsystem.com/patterns/loading-pattern/>
- <https://carbondesignsystem.com/components/progress-bar/usage/>
- <https://mui.com/material-ui/react-progress/>
The evidence gate covers request-signal propagation, user cancellation before workbook generation, immediate abort before download, worker bundling, production build output and an authenticated production interaction smoke.
## 2026-07-16: 50-cycle authenticated route soak
The production ECS application completed 50 authenticated `monitor -> history -> statistics -> tracks -> monitor` cycles. Chrome performance and heap metrics were sampled every five cycles. Because the Browser channel does not permit `HeapProfiler.collectGarbage`, the gate uses post-warmup and 15-second-idle low-water marks instead of comparing transient allocation peaks.
@@ -59,7 +73,7 @@ The navigation and progressive-loading direction follows mature observability sy
1. Capture an allocation profile for data-heavy operator interactions: loaded history series, long track playback and repeated Excel exports.
2. Bound or explicitly evict other high-cardinality query families, especially arbitrary history windows and track playback ranges.
3. Retire or migrate the remaining legacy `App.tsx` integration suite after its compatibility coverage is replaced in V2.
4. Preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
4. Profile repeated large exports after the Web Worker migration and define an explicit maximum supported row/column envelope for browser-side workbooks.
5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200.
## 2026-07-15: query memory and vehicle-count semantics