From 22d069a4044202fb4aa7d13d45056cdb0f1cbec9 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 08:23:45 +0800 Subject: [PATCH] fix(web): stabilize client file downloads --- .../apps/web/src/v2/domain/download.test.ts | 29 +++++++++++++++++++ .../apps/web/src/v2/domain/download.ts | 16 ++++++++++ .../apps/web/src/v2/domain/mileageExport.ts | 10 ++----- .../apps/web/src/v2/domain/track.ts | 8 ++--- .../apps/web/src/v2/pages/AccessPage.tsx | 4 +-- 5 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/download.test.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/download.ts diff --git a/vehicle-data-platform/apps/web/src/v2/domain/download.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/download.test.ts new file mode 100644 index 00000000..828bfc66 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/download.test.ts @@ -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'); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/download.ts b/vehicle-data-platform/apps/web/src/v2/domain/download.ts new file mode 100644 index 00000000..a85d7d3e --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/download.ts @@ -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); + } +} diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts index 150705b0..ba7642d3 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts @@ -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`); } diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.ts index e3382e0b..9dd0ecd0 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.ts @@ -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[]) { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index c19203f4..d4687ee4 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -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() {