From bea3b45fd870c7bf1eec607facec943516226035 Mon Sep 17 00:00:00 2001 From: DengQingNian <117324885+DengQingNian@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:39:11 +0800 Subject: [PATCH] =?UTF-8?q?perf(export):=20=E4=BC=98=E5=8C=96=E5=AF=BC?= =?UTF-8?q?=E5=87=BAui=E4=BD=93=E9=AA=8C=E5=92=8C=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(getting-started): set brew upgrade to use official homebrew cask * fix(mysql): make admin show handling dialect aware * fix(data-compare): create missing target tables during sync * feat(sql): suggest join conditions from foreign keys * fix(sidebar): use filtered order for range selection * fix(tabs): close connection tabs on disconnect * fix(sqlserver): execute cte queries without wrapping * fix(opengauss): open table structure editor * perf(export): 优化导出ui体验和性能 * perf(export): 优化导出ui体验和性能 * fix(export): normalize status casing and fix web route issues - Add missing table export web routes (POST /export/table, SSE progress, cancel) - Fix tauri table export Promise to wait for terminal events - Change cancel to return Ok(()) so it doesn't get overwritten as Error - Unify ExportStatus to PascalCase across all frontend components - Fix http.ts table export POST body wrapping - Fix duplicate pub mod in dbx-web routes * fix(export): return export file for browser download in web mode Write table export output to temp dir, register a download endpoint, and trigger browser download from the frontend when export completes. * fix(export): avoid table export progress races --------- Co-authored-by: Francesco Lucarelli Co-authored-by: t8y2 <1156263951@qq.com> Co-authored-by: dqn --- .../export/ExportProgressDialog.vue | 118 +++++ apps/desktop/src/components/grid/DataGrid.vue | 16 + .../src/components/objects/ObjectBrowser.vue | 175 +++++--- .../src/components/sidebar/TreeItem.vue | 223 +++++---- .../src/composables/useDataGridExport.ts | 87 +++- apps/desktop/src/i18n/locales/en.ts | 12 + apps/desktop/src/i18n/locales/es.ts | 12 + apps/desktop/src/i18n/locales/zh-CN.ts | 12 + apps/desktop/src/lib/api.ts | 5 + apps/desktop/src/lib/http.ts | 64 +++ apps/desktop/src/lib/tauri.ts | 71 +++ crates/dbx-core/src/csv_export.rs | 4 +- crates/dbx-core/src/database_export.rs | 5 +- crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/table_export.rs | 422 ++++++++++++++++++ crates/dbx-web/src/main.rs | 6 + crates/dbx-web/src/routes/mod.rs | 1 + crates/dbx-web/src/routes/table_export.rs | 152 +++++++ crates/dbx-web/src/state.rs | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/table_export.rs | 50 +++ src-tauri/src/lib.rs | 2 + 22 files changed, 1282 insertions(+), 159 deletions(-) create mode 100644 apps/desktop/src/components/export/ExportProgressDialog.vue create mode 100644 crates/dbx-core/src/table_export.rs create mode 100644 crates/dbx-web/src/routes/table_export.rs create mode 100644 src-tauri/src/commands/table_export.rs diff --git a/apps/desktop/src/components/export/ExportProgressDialog.vue b/apps/desktop/src/components/export/ExportProgressDialog.vue new file mode 100644 index 0000000000..4d835a5392 --- /dev/null +++ b/apps/desktop/src/components/export/ExportProgressDialog.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6040c426aa..d1e472f96b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -181,6 +181,7 @@ import { import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; +import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; @@ -3432,6 +3433,18 @@ function setRowStatusFilter(value: string) { rowStatusFilter.value = value as RowStatusFilter; } +// --- Export progress dialog state --- +const exportProgressDialog = ref(false); +const exportProgressState = ref({ + title: "", + tableName: "", + format: "csv" as string, + rowsExported: 0, + totalRows: null as number | null, + status: "", + errorMessage: null as string | null, +}); + // --- Export composable --- const { copyText, @@ -3473,6 +3486,8 @@ const { selectedRowIds, hasRowSelection, fullExportResult: props.fullExportResult, + exportProgressDialog, + exportProgressState, }); const pageSizeMenuItems = computed(() => @@ -7692,6 +7707,7 @@ const gridContextMenuItems = computed(() => { @confirm="confirmDeleteRow" /> + diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 0df5bdd9a0..1200f1f112 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -42,6 +42,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; +import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import * as api from "@/lib/api"; import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database"; import { isSchemaAware } from "@/lib/databaseCapabilities"; @@ -69,10 +70,10 @@ import { import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; import { buildViewDdl } from "@/lib/viewDdl"; import { isTauriRuntime } from "@/lib/tauriRuntime"; +import { generateDatabaseExportId } from "@/lib/databaseExport"; import { copyToClipboard } from "@/lib/clipboard"; import { formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; -import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -155,6 +156,16 @@ const showBatchDropConfirm = ref(false); const batchDropPreviewSql = ref(""); let loadId = 0; +// Export progress state +const showExportProgress = ref(false); +const exportProgressId = ref(""); +const exportProgressTableName = ref(""); +const exportProgressFormat = ref(""); +const exportProgressRows = ref(0); +const exportProgressTotal = ref(null); +const exportProgressStatus = ref(""); +const exportProgressError = ref(null); + const needsSchema = computed(() => isSchemaAware(props.connection.db_type)); const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length); const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); @@ -772,7 +783,7 @@ async function exportStructure(row: ObjectBrowserRow) { } } -async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { +async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { try { const schema = row.schema || selectedSchema.value; const queryColumns = @@ -781,54 +792,14 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") (column) => column.name, ) : undefined; - - if (format === "csv" && isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const outputPath = await save({ - defaultPath: `${row.name}.csv`, - filters: [{ name: "CSV", extensions: ["csv"] }], - }); - if (!outputPath) return; - await api.exportTableDataCsv({ - filePath: outputPath as string, - connectionId: props.connection.id, - database: props.database, - schema, - tableName: row.name, - columns: queryColumns, - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }); - toast(t("grid.exported")); - return; - } - const result = await fetchTableDataForExport({ databaseType: props.connection.db_type, schema, tableName: row.name, columns: queryColumns, - executePage: (sql) => - api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, { - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }), + executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql), }); - if (format === "csv") { - let outputPath = `${row.name}.csv`; - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const path = await save({ - defaultPath: outputPath, - filters: [{ name: "CSV", extensions: ["csv"] }], - }); - if (!path) return; - outputPath = path as string; - } - await api.exportQueryResultCsv(outputPath, result.columns, result.rows); - toast(t("grid.exported")); - return; - } - if (format === "json") { let outputPath = `${row.name}.json`; if (isTauriRuntime()) { @@ -859,40 +830,94 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") } } -async function exportDataXlsx(row: ObjectBrowserRow) { - try { - const schema = row.schema || selectedSchema.value; - const queryColumns = - props.connection.db_type === "neo4j" - ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( - (column) => column.name, - ) - : undefined; - const result = await fetchTableDataForExport({ - databaseType: props.connection.db_type, - schema, - tableName: row.name, - columns: queryColumns, - executePage: (sql) => - api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, { - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }), - }); +async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { + if (format === "csv") { + await exportTableData(row, "csv"); + return; + } + await exportDataLegacy(row, format); +} - let outputPath = `${row.name}.xlsx`; - if (isTauriRuntime()) { +async function exportDataXlsx(row: ObjectBrowserRow) { + await exportTableData(row, "xlsx"); +} + +async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") { + const schema = row.schema || selectedSchema.value; + + // Save dialog first + let filePath = ""; + const defaultName = `${row.name}.${format}`; + + if (isTauriRuntime()) { + try { const { save } = await import("@tauri-apps/plugin-dialog"); + const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : { name: "Excel", extensions: ["xlsx"] }; const path = await save({ - defaultPath: outputPath, - filters: [{ name: "Excel", extensions: ["xlsx"] }], + defaultPath: defaultName, + filters: [filter], }); if (!path) return; - outputPath = path as string; + filePath = path as string; + } catch (e: any) { + toast(e?.message || String(e), 5000); + return; } - await api.exportQueryResultXlsx(outputPath, row.name, result.columns, result.rows); + } else { + const webExportId = generateDatabaseExportId(); + filePath = `__web_export_${webExportId}.${format}`; + } + + // Set up progress state and open dialog + const exportId = generateDatabaseExportId(); + exportProgressId.value = exportId; + exportProgressTableName.value = row.name; + exportProgressFormat.value = format; + exportProgressRows.value = 0; + exportProgressTotal.value = null; + exportProgressStatus.value = "Running"; + exportProgressError.value = null; + showExportProgress.value = true; + + // Get columns for neo4j only + const queryColumns = + props.connection.db_type === "neo4j" + ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( + (column) => column.name, + ) + : undefined; + + const request: api.TableExportRequest = { + exportId, + connectionId: props.connection.id, + database: props.database, + schema, + tableName: row.name, + filePath, + format, + columns: queryColumns, + }; + + try { + await api.startTableExport(request, (progress) => { + exportProgressRows.value = progress.rowsExported; + exportProgressTotal.value = progress.totalRows; + exportProgressStatus.value = progress.status; + exportProgressError.value = progress.errorMessage || null; + }); toast(t("grid.exported")); } catch (e: any) { - toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + if (exportProgressStatus.value !== "Cancelled") { + exportProgressStatus.value = "Error"; + exportProgressError.value = e?.message || String(e); + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + } +} + +async function cancelExport() { + if (exportProgressId.value) { + await api.cancelTableExport(exportProgressId.value); } } @@ -1683,6 +1708,18 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + +