fix(web): stabilize client file downloads
This commit is contained in:
@@ -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');
|
||||
});
|
||||
16
vehicle-data-platform/apps/web/src/v2/domain/download.ts
Normal file
16
vehicle-data-platform/apps/web/src/v2/domain/download.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user