diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index 1c6d4b2..14e7bf1 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -12,6 +12,7 @@ import { collectUsageDetails, extractTotalTokens } from '@/utils/usage'; +import { downloadBlob } from '@/utils/download'; import styles from '@/pages/UsagePage.module.scss'; const ALL_FILTER = '__all__'; @@ -53,17 +54,9 @@ const toNumber = (value: unknown): number => { const encodeCsv = (value: string | number): string => { const text = String(value ?? ''); - return `"${text.replace(/"/g, '""')}"`; -}; - -const downloadFile = (filename: string, content: string, mimeType: string) => { - const blob = new Blob([content], { type: mimeType }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); + const trimmedLeft = text.replace(/^\s+/, ''); + const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text; + return `"${safeText.replace(/"/g, '""')}"`; }; type CredentialInfo = { @@ -370,7 +363,10 @@ export function RequestEventsDetailsCard({ const content = [csvHeader.join(','), ...csvRows].join('\n'); const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); - downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8'); + downloadBlob({ + filename: `usage-events-${fileTime}.csv`, + blob: new Blob([content], { type: 'text/csv;charset=utf-8' }) + }); }; const handleExportJson = () => { @@ -394,7 +390,10 @@ export function RequestEventsDetailsCard({ const content = JSON.stringify(payload, null, 2); const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); - downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8'); + downloadBlob({ + filename: `usage-events-${fileTime}.json`, + blob: new Blob([content], { type: 'application/json;charset=utf-8' }) + }); }; return ( diff --git a/src/components/usage/hooks/useUsageData.ts b/src/components/usage/hooks/useUsageData.ts index e68cc0b..4816cef 100644 --- a/src/components/usage/hooks/useUsageData.ts +++ b/src/components/usage/hooks/useUsageData.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores'; import { usageApi } from '@/services/api/usage'; +import { downloadBlob } from '@/utils/download'; import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage'; export interface UsagePayload { @@ -62,13 +63,10 @@ export function useUsageData(): UseUsageDataReturn { ? new Date().toISOString() : exportedAt.toISOString(); const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`; - const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); - window.URL.revokeObjectURL(url); + downloadBlob({ + filename, + blob: new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' }) + }); showNotification(t('usage_stats.export_success'), 'success'); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; diff --git a/src/features/authFiles/hooks/useAuthFilesData.ts b/src/features/authFiles/hooks/useAuthFilesData.ts index e74781a..a3d2c08 100644 --- a/src/features/authFiles/hooks/useAuthFilesData.ts +++ b/src/features/authFiles/hooks/useAuthFilesData.ts @@ -6,6 +6,7 @@ import { useNotificationStore } from '@/stores'; import type { AuthFileItem } from '@/types'; import { formatFileSize } from '@/utils/format'; import { MAX_AUTH_FILE_SIZE } from '@/utils/constants'; +import { downloadBlob } from '@/utils/download'; import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants'; type DeleteAllOptions = { @@ -316,12 +317,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles { responseType: 'blob' } ); const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = name; - a.click(); - window.URL.revokeObjectURL(url); + downloadBlob({ filename: name, blob }); showNotification(t('auth_files.download_success'), 'success'); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index 519cb3a..34110f1 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -24,6 +24,7 @@ import { logsApi } from '@/services/api/logs'; import { usageApi } from '@/services/api/usage'; import type { AuthFileItem } from '@/types'; import { copyToClipboard } from '@/utils/clipboard'; +import { downloadBlob } from '@/utils/download'; import { MANAGEMENT_API_PREFIX } from '@/utils/constants'; import { formatUnixTimestamp } from '@/utils/format'; import { @@ -710,13 +711,7 @@ export function LogsPage() { const downloadLogs = () => { const text = logState.buffer.join('\n'); - const blob = new Blob([text], { type: 'text/plain' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'logs.txt'; - a.click(); - window.URL.revokeObjectURL(url); + downloadBlob({ filename: 'logs.txt', blob: new Blob([text], { type: 'text/plain' }) }); showNotification(t('logs.download_success'), 'success'); }; @@ -747,13 +742,7 @@ export function LogsPage() { const downloadErrorLog = async (name: string) => { try { const response = await logsApi.downloadErrorLog(name); - const blob = new Blob([response.data], { type: 'text/plain' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = name; - a.click(); - window.URL.revokeObjectURL(url); + downloadBlob({ filename: name, blob: new Blob([response.data], { type: 'text/plain' }) }); showNotification(t('logs.error_log_download_success'), 'success'); } catch (err: unknown) { const message = getErrorMessage(err); @@ -1187,13 +1176,10 @@ export function LogsPage() { setRequestLogDownloading(true); try { const response = await logsApi.downloadRequestLogById(id); - const blob = new Blob([response.data], { type: 'text/plain' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `request-${id}.log`; - a.click(); - window.URL.revokeObjectURL(url); + downloadBlob({ + filename: `request-${id}.log`, + blob: new Blob([response.data], { type: 'text/plain' }) + }); showNotification(t('logs.request_log_download_success'), 'success'); setRequestLogId(null); } catch (err: unknown) { diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..2dbc497 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,22 @@ +export type DownloadBlobOptions = { + filename: string; + blob: Blob; + revokeDelayMs?: number; +}; + +export function downloadBlob({ filename, blob, revokeDelayMs = 1000 }: DownloadBlobOptions) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.rel = 'noopener'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + + window.setTimeout(() => { + window.URL.revokeObjectURL(url); + link.remove(); + }, revokeDelayMs); +} +