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:
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>
|
||||
|
||||
Reference in new issue
Block a user