fix: 增加下载完成提示弹窗

This commit is contained in:
2026-05-21 17:41:15 +08:00
parent a411204821
commit b5b614423c
6 changed files with 66 additions and 21 deletions
+5
View File
@@ -1,4 +1,9 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-21:增加下载完成提示弹窗
- 数据表 CSV/XLSX 导出、历史数据压缩包下载和配置文件下载在下载流程完成后会弹出 Naive UI 成功对话框,使用绿色成功图标提示用户文件已下载完成。
- 提示逻辑统一放在 `frontend/src/composables/downloadFeedback.ts`;桌面端会在 Tauri 原生保存流程确认写入后提示,用户取消保存时不弹完成提示,Web 端会在浏览器下载触发后提示。
## 2026-05-21:排查离线机器桌面端白屏 ## 2026-05-21:排查离线机器桌面端白屏
- 运行时外链扫描确认:`frontend/src`、`app`、`src-tauri/src` 中没有 CDN、在线字体或外网业务接口;桌面端前端只访问本机 `http://127.0.0.1:9081` sidecar,`src-tauri/tauri.conf.json` 的 `https://schema.tauri.app/config/2` 只是编辑器/构建 schema,不参与用户机器运行。 - 运行时外链扫描确认:`frontend/src`、`app`、`src-tauri/src` 中没有 CDN、在线字体或外网业务接口;桌面端前端只访问本机 `http://127.0.0.1:9081` sidecar,`src-tauri/tauri.conf.json` 的 `https://schema.tauri.app/config/2` 只是编辑器/构建 schema,不参与用户机器运行。
+11 -11
View File
@@ -149,7 +149,7 @@ export function upload<T>(
}); });
} }
export async function download(url: string, body: unknown, filename: string): Promise<void> { export async function download(url: string, body: unknown, filename: string): Promise<boolean> {
const headers = new Headers({ 'Content-Type': 'application/json' }); const headers = new Headers({ 'Content-Type': 'application/json' });
const token = getToken(); const token = getToken();
if (token) { if (token) {
@@ -157,14 +157,13 @@ export async function download(url: string, body: unknown, filename: string): Pr
} }
if (isTauriRuntime()) { if (isTauriRuntime()) {
await downloadWithTauri({ return await downloadWithTauri({
url: apiUrl(url), url: apiUrl(url),
method: 'POST', method: 'POST',
filename, filename,
headers: headersToPairs(headers), headers: headersToPairs(headers),
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
return;
} }
const response = await fetchWithRetry(apiUrl(url), { const response = await fetchWithRetry(apiUrl(url), {
@@ -178,10 +177,10 @@ export async function download(url: string, body: unknown, filename: string): Pr
throw new ApiRequestError(error.message, response.status, error.detail); throw new ApiRequestError(error.message, response.status, error.detail);
} }
await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
} }
export async function downloadGet(url: string, fallbackFilename: string): Promise<void> { export async function downloadGet(url: string, fallbackFilename: string): Promise<boolean> {
const headers = new Headers(); const headers = new Headers();
const token = getToken(); const token = getToken();
if (token) { if (token) {
@@ -189,13 +188,12 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
} }
if (isTauriRuntime()) { if (isTauriRuntime()) {
await downloadWithTauri({ return await downloadWithTauri({
url: apiUrl(url), url: apiUrl(url),
method: 'GET', method: 'GET',
filename: fallbackFilename, filename: fallbackFilename,
headers: headersToPairs(headers) headers: headersToPairs(headers)
}); });
return;
} }
const response = await fetchWithRetry(apiUrl(url), { headers }); const response = await fetchWithRetry(apiUrl(url), { headers });
@@ -214,7 +212,7 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
} }
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename; const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
await saveBlobResponse(response, filename); return await saveBlobResponse(response, filename);
} }
async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> { async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> {
@@ -249,7 +247,7 @@ function parseApiError(data: ApiError, fallback: string): { message: string; det
}; };
} }
async function saveBlobResponse(response: Response, filename: string): Promise<void> { async function saveBlobResponse(response: Response, filename: string): Promise<boolean> {
const blob = await response.blob(); const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
@@ -257,12 +255,14 @@ async function saveBlobResponse(response: Response, filename: string): Promise<v
link.download = filename; link.download = filename;
link.click(); link.click();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
return true;
} }
async function downloadWithTauri(request: TauriDownloadRequest): Promise<void> { async function downloadWithTauri(request: TauriDownloadRequest): Promise<boolean> {
try { try {
const invoke = await getTauriInvoke(); const invoke = await getTauriInvoke();
await invoke<TauriDownloadResult>('download_to_file', { request }); const result = await invoke<TauriDownloadResult>('download_to_file', { request });
return result.saved;
} catch (error) { } catch (error) {
throw parseTauriDownloadError(error); throw parseTauriDownloadError(error);
} }
+15 -6
View File
@@ -231,6 +231,7 @@ import {
import { apiGet, apiPost, download } from '../api/client'; import { apiGet, apiPost, download } from '../api/client';
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types'; import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type RowData = Record<string, unknown>; type RowData = Record<string, unknown>;
@@ -483,14 +484,18 @@ async function downloadSelectedCsv() {
if (!selectedCsvTable.value) return; if (!selectedCsvTable.value) return;
if (downloadingFormat.value) return; if (downloadingFormat.value) return;
const filename = `${selectedCsvTable.value}.csv`;
downloadingFormat.value = 'csv'; downloadingFormat.value = 'csv';
try { try {
await download( const completed = await download(
'/api/download', '/api/download',
{ table_name: selectedCsvTable.value, format: 'csv' }, { table_name: selectedCsvTable.value, format: 'csv' },
`${selectedCsvTable.value}.csv` filename
); );
csvDialogVisible.value = false; if (completed) {
csvDialogVisible.value = false;
showDownloadCompleteDialog(dialog, filename);
}
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '导出失败'); message.error(error instanceof Error ? error.message : '导出失败');
} finally { } finally {
@@ -503,13 +508,17 @@ async function downloadSelectedXlsx() {
if (downloadingFormat.value) return; if (downloadingFormat.value) return;
downloadingFormat.value = 'xlsx'; downloadingFormat.value = 'xlsx';
const filename = selectedXlsxTables.value.length === 1 ? `${selectedXlsxTables.value[0]}.xlsx` : 'tables.xlsx';
try { try {
await download( const completed = await download(
'/api/download', '/api/download',
{ table_names: selectedXlsxTables.value, format: 'xlsx' }, { table_names: selectedXlsxTables.value, format: 'xlsx' },
selectedXlsxTables.value.length === 1 ? `${selectedXlsxTables.value[0]}.xlsx` : 'tables.xlsx' filename
); );
xlsxDialogVisible.value = false; if (completed) {
xlsxDialogVisible.value = false;
showDownloadCompleteDialog(dialog, filename);
}
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '导出失败'); message.error(error instanceof Error ? error.message : '导出失败');
} finally { } finally {
+6 -2
View File
@@ -100,6 +100,7 @@ import { CopyOutline, DownloadOutline, FileTrayOutline, RefreshOutline, TrashOut
import { apiGet, apiPost, download as downloadFile } from '../api/client'; import { apiGet, apiPost, download as downloadFile } from '../api/client';
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types'; import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error'; type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
@@ -184,8 +185,11 @@ async function downloadHistory(record: HistoryRecord) {
downloadingRecordId.value = record.id; downloadingRecordId.value = record.id;
try { try {
await downloadFile('/api/history/download', { record_id: record.id }, `${record.id}.zip`); const filename = `${record.id}.zip`;
message.success('历史数据下载已开始'); const completed = await downloadFile('/api/history/download', { record_id: record.id }, filename);
if (completed) {
showDownloadCompleteDialog(dialog, filename);
}
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '下载历史数据失败'); message.error(error instanceof Error ? error.message : '下载历史数据失败');
} finally { } finally {
+8 -2
View File
@@ -312,11 +312,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useMessage, type SelectOption } from 'naive-ui'; import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5'; import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5';
import { apiGet, apiPost, downloadGet, upload } from '../api/client'; import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, HistoryRetentionConfig, RemoteDataConfig } from '../types'; import type { ApiMessage, AppConfig, HistoryRetentionConfig, RemoteDataConfig } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
interface ExtractFieldConfig { interface ExtractFieldConfig {
@@ -327,6 +328,7 @@ interface ExtractFieldConfig {
} }
const message = useMessage(); const message = useMessage();
const dialog = useDialog();
const configInput = ref<HTMLInputElement | null>(null); const configInput = ref<HTMLInputElement | null>(null);
const configUpdate = ref(''); const configUpdate = ref('');
const loading = ref(false); const loading = ref(false);
@@ -661,7 +663,11 @@ async function changePassword() {
async function downloadConfig() { async function downloadConfig() {
try { try {
await downloadGet('/api/config/download', 'Configure.json'); const filename = 'Configure.json';
const completed = await downloadGet('/api/config/download', filename);
if (completed) {
showDownloadCompleteDialog(dialog, filename);
}
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '下载配置失败'); message.error(error instanceof Error ? error.message : '下载配置失败');
} }
@@ -0,0 +1,21 @@
interface DownloadDialog {
success(options: {
title: string;
content: string;
positiveText?: string;
closable?: boolean;
maskClosable?: boolean;
}): unknown;
}
export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string): void {
const safeName = filename?.trim();
dialog.success({
title: '下载完成',
content: safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。',
positiveText: '知道了',
closable: true,
maskClosable: true
});
}