perf(export): 优化导出ui体验和性能

* 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 <francesco06lucarelli@gmail.com>
Co-authored-by: t8y2 <1156263951@qq.com>
Co-authored-by: dqn <someonegdeng@qq.com>
This commit is contained in:
authored and GitHub committed 2026-06-03 10:39:11 +08:00
1 parent bed72d2132
commit bea3b45fd8
22 files changed
+1282 -159

No files matched your search

@@ -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<number | null>(null);
const exportProgressStatus = ref<string>("");
const exportProgressError = ref<string | null>(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[] {
</DialogFooter>
</DialogContent>
</Dialog>
<ExportProgressDialog
v-model:open="showExportProgress"
:title="t('grid.exporting')"
:table-name="exportProgressTableName"
:format="exportProgressFormat"
:rows-exported="exportProgressRows"
:total-rows="exportProgressTotal"
:status="exportProgressStatus"
:error-message="exportProgressError"
@cancel="cancelExport"
/>
</template>
<style scoped>