fix(web): stabilize client file downloads

This commit is contained in:
lingniu
2026-07-16 08:23:45 +08:00
parent 4b2e743229
commit 22d069a404
5 changed files with 51 additions and 16 deletions

View File

@@ -0,0 +1,29 @@
import { afterEach, expect, test, vi } from 'vitest';
import { DOWNLOAD_URL_REVOKE_MS, downloadBlob } from './download';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
document.body.replaceChildren();
});
test('keeps the blob URL alive through the click and revokes it after cleanup', () => {
vi.useFakeTimers();
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:vehicle-export');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.isConnected).toBe(true);
expect(this.href).toContain('blob:vehicle-export');
expect(this.download).toBe('车辆导出.csv');
expect(revokeObjectURL).not.toHaveBeenCalled();
});
downloadBlob(new Blob(['vehicle']), '车辆导出.csv');
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(click).toHaveBeenCalledTimes(1);
expect(document.querySelector('a[download]')).toBeNull();
expect(revokeObjectURL).not.toHaveBeenCalled();
vi.advanceTimersByTime(DOWNLOAD_URL_REVOKE_MS);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:vehicle-export');
});

View File

@@ -0,0 +1,16 @@
export const DOWNLOAD_URL_REVOKE_MS = 1_000;
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.hidden = true;
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_REVOKE_MS);
}
}

View File

@@ -1,4 +1,5 @@
import type { DailyMileageRow } from '../../api/types';
import { downloadBlob } from './download';
export type MileageExportVehicle = { vin: string; plate: string };
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
@@ -60,12 +61,5 @@ export async function downloadMileageWorkbook(input: MileageExportInput, signal?
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');
anchor.href = url;
anchor.download = `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${input.vehicles.length}辆.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
downloadBlob(blob, `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${input.vehicles.length}辆.xlsx`);
}

View File

@@ -1,4 +1,5 @@
import type { HistoryLocationRow, TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
import { downloadBlob } from './download';
export const TRACK_ADDRESS_SETTLE_MS = 650;
@@ -70,12 +71,7 @@ export function trackCsv(track: TrackPlaybackResponse) {
export function downloadTrackCsv(track: TrackPlaybackResponse) {
const blob = new Blob([trackCsv(track)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `track-${track.plate || track.vin}-${track.summary.startTime.slice(0, 10) || 'latest'}.csv`;
link.click();
URL.revokeObjectURL(url);
downloadBlob(blob, `track-${track.plate || track.vin}-${track.summary.startTime.slice(0, 10) || 'latest'}.csv`);
}
export function validTrackPoints(points: HistoryLocationRow[]) {

View File

@@ -9,6 +9,7 @@ import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { downloadBlob } from '../domain/download';
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
@@ -88,8 +89,7 @@ function ThresholdSettings({ config, draft, editable, saving, error, onChange, o
function downloadRows(rows: AccessVehicleRow[]) {
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
const href = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(href);
downloadBlob(blob, `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`);
}
export default function AccessPage() {