feat: export entire database as SQL file

Add a new "Export Database" feature accessible from the sidebar
right-click menu on database/schema nodes. Generates a .sql file
containing CREATE TABLE DDL, batched INSERT statements, and
view/procedure/function source code.

Backend (database_export.rs):
- Stream-writes SQL to file with configurable batch size
- Supports all SQL database types via existing DDL/INSERT generators
- Progress events with cancellation support
- Graceful error handling (continues on per-table failures)

Frontend (DatabaseExportDialog.vue):
- Connection/database/schema selector
- Checkboxes: structure, data, objects
- Real-time progress with current table and row count
- Cancel button

Wired up through Tauri commands, Web SSE routes, and API layer.
This commit is contained in:
t8y2
2026-05-13 01:08:16 +08:00
parent 83216b2765
commit 9ba2976d11
14 changed files with 1134 additions and 139 deletions
@@ -0,0 +1,381 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import type { ExportProgress } from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Download, Square, CheckSquare, X } from "lucide-vue-next";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
}>();
// Connection / Database / Schema selectors
const connectionId = ref("");
const database = ref("");
const databases = ref<string[]>([]);
const schema = ref("");
const schemas = ref<string[]>([]);
const loadingMeta = ref(false);
// Options
const includeStructure = ref(true);
const includeData = ref(true);
const includeObjects = ref(true);
// Export state
const isExporting = ref(false);
const exportProgress = ref<ExportProgress | null>(null);
const exportId = ref("");
const exportDone = ref(false);
const exportError = ref<string | null>(null);
const exportCancelled = ref(false);
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
);
const canExport = computed(
() =>
connectionId.value &&
database.value &&
schema.value &&
(includeStructure.value || includeData.value || includeObjects.value) &&
!isExporting.value,
);
function connectionIconType(connId: string) {
const config = store.getConfig(connId);
return config?.driver_profile || config?.db_type || "mysql";
}
async function loadDatabases(connId: string) {
if (!connId) return;
loadingMeta.value = true;
try {
await store.ensureConnected(connId);
const dbs = await api.listDatabases(connId);
const names = dbs.map((d) => d.name);
databases.value = names;
database.value = names.length === 1 ? names[0] : "";
schemas.value = [];
schema.value = "";
} catch {
databases.value = [];
} finally {
loadingMeta.value = false;
}
}
async function loadSchemas(preferredSchema = "") {
if (!connectionId.value || !database.value) return;
const config = store.getConfig(connectionId.value);
if (!isSchemaAware(config?.db_type)) {
schemas.value = [];
schema.value = database.value;
return;
}
const schemaList = await api.listSchemas(connectionId.value, database.value);
const selected =
preferredSchema && schemaList.includes(preferredSchema)
? preferredSchema
: schemaList.includes("public")
? "public"
: (schemaList[0] ?? "");
schemas.value = schemaList;
schema.value = selected;
}
async function startExport() {
if (!canExport.value) return;
isExporting.value = true;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportProgress.value = null;
exportId.value = crypto.randomUUID();
let filePath = "";
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const safeName = (database.value || "database").replace(/[\\/:*?"<>|]+/g, "_").trim();
const path = await save({
defaultPath: `${safeName}.sql`,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) {
isExporting.value = false;
return;
}
filePath = path;
} catch (e: any) {
isExporting.value = false;
toast(e?.message || String(e), 5000);
return;
}
} else {
// Web mode: use a temp path; the server will handle the file
filePath = `__web_export_${exportId.value}.sql`;
}
const request: api.DatabaseExportRequest = {
exportId: exportId.value,
connectionId: connectionId.value,
database: database.value,
schema: schema.value,
filePath,
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
batchSize: 1000,
};
try {
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
if (progress.status === "Done") {
exportDone.value = true;
isExporting.value = false;
toast(t("databaseExport.exportSuccess"), 3000);
} else if (progress.status === "Error") {
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
exportCancelled.value = true;
isExporting.value = false;
}
});
} catch (e: any) {
exportError.value = e?.message || String(e);
isExporting.value = false;
}
}
async function cancelExport() {
if (exportId.value) {
await api.cancelDatabaseExport(exportId.value);
}
}
function resetState() {
connectionId.value = "";
database.value = "";
databases.value = [];
schema.value = "";
schemas.value = [];
includeStructure.value = true;
includeData.value = true;
includeObjects.value = true;
isExporting.value = false;
exportProgress.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportId.value = "";
}
const progressPercent = computed(() => {
const p = exportProgress.value;
if (!p || p.totalObjects === 0) return 0;
return Math.round((p.objectIndex / p.totalObjects) * 100);
});
const skipConnectionWatch = ref(false);
watch(connectionId, (id) => {
if (skipConnectionWatch.value) {
skipConnectionWatch.value = false;
return;
}
database.value = "";
databases.value = [];
schemas.value = [];
schema.value = "";
loadDatabases(id);
});
watch(database, (db) => {
schema.value = "";
schemas.value = [];
if (db) loadSchemas(props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(open, async (val) => {
if (val) {
resetState();
if (props.prefillConnectionId) {
skipConnectionWatch.value = true;
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
if (props.prefillDatabase) {
database.value = props.prefillDatabase;
await loadSchemas(props.prefillSchema);
}
}
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px] max-h-[80vh] flex flex-col overflow-hidden" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Download class="w-4 h-4" />
{{ t("databaseExport.title") }}
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
<!-- Connection / Database / Schema Selection -->
<div v-if="!isExporting && !exportDone && !exportError && !exportCancelled" class="space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceConnection") }}</Label>
<Select :model-value="connectionId" @update:model-value="(v: any) => (connectionId = String(v))">
<SelectTrigger class="h-8 text-xs">
<div class="flex items-center gap-2">
<DatabaseIcon v-if="connectionId" :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5" />
<SelectValue :placeholder="t('diff.selectConnection')" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-2">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="databases.length" class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
<Select :model-value="database" @update:model-value="(v: any) => (database = String(v))">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="schemas.length" class="space-y-1.5">
<Label class="text-xs">{{ t("diff.selectSchema") }}</Label>
<Select :model-value="schema" @update:model-value="(v: any) => (schema = String(v))">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Options -->
<div class="space-y-2.5 pt-1">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("settings.options") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeStructure = !includeStructure">
<CheckSquare v-if="includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeStructure") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeData = !includeData">
<CheckSquare v-if="includeData" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeData") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeObjects = !includeObjects">
<CheckSquare v-if="includeObjects" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeObjects") }}
</div>
</div>
</div>
<!-- Progress View -->
<div v-if="isExporting || exportDone || exportError || exportCancelled" class="py-3 space-y-3">
<div v-if="exportProgress" class="space-y-2">
<div class="text-xs text-muted-foreground">
{{
t("databaseExport.currentTable", {
table: exportProgress.currentObject,
current: exportProgress.objectIndex,
total: exportProgress.totalObjects,
})
}}
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : 'bg-primary'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="text-xs text-muted-foreground">
{{ t("databaseExport.rowsExported", { count: exportProgress.rowsExported.toLocaleString() }) }}
</div>
</div>
<!-- Status messages -->
<div v-if="exportDone" class="text-xs text-green-600 font-medium">
{{ t("databaseExport.exportSuccess") }}
</div>
<div v-else-if="exportError" class="text-xs text-destructive font-medium">
{{ t("databaseExport.exportError", { error: exportError }) }}
</div>
<div v-else-if="exportCancelled" class="text-xs text-yellow-600 font-medium">
{{ t("databaseExport.exportCancelled") }}
</div>
</div>
</div>
<DialogFooter>
<template v-if="!isExporting && !exportDone && !exportError && !exportCancelled">
<Button variant="outline" size="sm" @click="open = false">
{{ t("transfer.cancel") }}
</Button>
<Button size="sm" :disabled="!canExport" @click="startExport">
<Download class="w-3.5 h-3.5 mr-1.5" />
{{ t("databaseExport.export") }}
</Button>
</template>
<template v-else-if="isExporting">
<Button variant="destructive" size="sm" @click="cancelExport">
<X class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.cancel") }}
</Button>
</template>
<template v-else>
<Button size="sm" @click="open = false">
{{ t("common.close") }}
</Button>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+7
View File
@@ -18,6 +18,7 @@ const TableStructureEditorDialog = defineAsyncComponent(
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useDialogSources } from "@/composables/useDialogSources";
@@ -173,6 +174,12 @@ watch(
:prefill-schema="dialogs.databaseSearchPrefillSchema.value"
@open-target="emit('openDatabaseSearchTarget', $event)"
/>
<DatabaseExportDialog
v-model:open="dialogs.showDatabaseExportDialog.value"
:prefill-connection-id="dialogs.databaseExportPrefillConnectionId.value"
:prefill-database="dialogs.databaseExportPrefillDatabase.value"
:prefill-schema="dialogs.databaseExportPrefillSchema.value"
/>
<ConfigPassphraseDialog
v-model:open="dialogs.showConfigPassphraseDialog.value"
:mode="dialogs.configPassphraseMode.value"
+13 -139
View File
@@ -57,18 +57,11 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { DatabaseType, QueryResult, TreeNode, TreeNodeType } from "@/types/database";
import type { DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/api";
import { uuid } from "@/lib/utils";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout";
import {
DATABASE_EXPORT_PAGE_SIZE,
DATABASE_EXPORT_ROW_LIMIT,
buildDatabaseSqlExport,
buildExportPageSql,
type ExportedTableSql,
} from "@/lib/databaseExport";
import {
buildTableSelectSql,
qualifiedTableName as buildQualifiedTableName,
@@ -125,8 +118,6 @@ const emit = defineEmits<{
"search-toggle": [node: TreeNode];
}>();
const isExportingDatabase = ref(false);
function currentDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
}
@@ -143,10 +134,6 @@ function qualifiedTableName(tableName: string, schema?: string): string {
});
}
function safeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]+/g, "_").trim() || "database";
}
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
switch (node.type) {
case "connection":
@@ -835,78 +822,6 @@ function createTable() {
};
}
async function collectDatabaseExportTables(): Promise<Array<{ schema?: string; name: string; displayName: string }>> {
const node = props.node;
if (!node.connectionId || !node.database) return [];
const config = connectionStore.getConfig(node.connectionId);
if (node.type === "schema" && node.schema) {
const tables = await api.listTables(node.connectionId, node.database, node.schema);
return tables.map((table) => ({
schema: node.schema,
name: table.name,
displayName: `${node.schema}.${table.name}`,
}));
}
if (isSchemaAware(config?.db_type)) {
const schemas = await api.listSchemas(node.connectionId, node.database);
const groups = await Promise.all(
schemas.map(async (schema) => {
const tables = await api.listTables(node.connectionId!, node.database!, schema);
return tables.map((table) => ({
schema,
name: table.name,
displayName: `${schema}.${table.name}`,
}));
}),
);
return groups.flat();
}
const tables = await api.listTables(node.connectionId, node.database, node.database);
return tables.map((table) => ({
name: table.name,
displayName: table.name,
}));
}
async function fetchExportTableRows(
connectionId: string,
database: string,
table: { schema?: string; name: string },
databaseType?: DatabaseType,
): Promise<{ columns: QueryResult["columns"]; rows: QueryResult["rows"]; truncated: boolean }> {
const rows: QueryResult["rows"] = [];
let columns: QueryResult["columns"] = [];
let offset = 0;
while (rows.length < DATABASE_EXPORT_ROW_LIMIT) {
const remaining = DATABASE_EXPORT_ROW_LIMIT - rows.length;
const limit =
databaseType === "sqlserver" ? DATABASE_EXPORT_ROW_LIMIT : Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining);
const sql = buildExportPageSql({
databaseType,
schema: table.schema,
tableName: table.name,
limit,
offset: databaseType === "sqlserver" ? undefined : offset,
});
const result = await api.executeQuery(connectionId, database, sql);
if (columns.length === 0) columns = result.columns;
rows.push(...result.rows);
if (result.rows.length < limit || databaseType === "sqlserver") break;
offset += result.rows.length;
}
return {
columns,
rows,
truncated: rows.length >= DATABASE_EXPORT_ROW_LIMIT,
};
}
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -954,56 +869,6 @@ async function saveBinaryFileContent(
}
}
async function exportDatabase() {
const node = props.node;
if (!(node.type === "database" || node.type === "schema") || !node.connectionId || !node.database) return;
isExportingDatabase.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const config = connectionStore.getConfig(node.connectionId);
const tables = await collectDatabaseExportTables();
const exportedTables: ExportedTableSql[] = [];
for (const table of tables) {
const querySchema = table.schema || node.database;
const qualifiedName = qualifiedTableName(table.name, table.schema);
const ddl = await api.getTableDdl(node.connectionId, node.database, querySchema, table.name);
const result = await fetchExportTableRows(node.connectionId, node.database, table, config?.db_type);
exportedTables.push({
displayName: table.displayName,
qualifiedTableName: qualifiedName,
ddl,
columns: result.columns,
rows: result.rows,
truncated: result.truncated,
});
}
const scopeName = node.type === "schema" && node.schema ? `${node.database}.${node.schema}` : node.database;
const content = buildDatabaseSqlExport({
databaseName: scopeName,
tables: exportedTables,
quoteIdentifier: quoteIdent,
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
});
await saveFileContent(content, `${safeFileName(scopeName)}.sql`, "SQL", "sql");
toast(
t("contextMenu.exportDatabaseSuccess", {
count: exportedTables.length,
limit: DATABASE_EXPORT_ROW_LIMIT,
}),
3000,
);
} catch (e: any) {
console.error("Export database failed:", e);
toast(t("contextMenu.exportDatabaseFailed", { message: e?.message || String(e) }), 5000);
} finally {
isExportingDatabase.value = false;
}
}
async function exportStructure() {
const node = props.node;
if (!node.connectionId || !node.database) return;
@@ -1152,6 +1017,16 @@ function openDatabaseSearch() {
};
}
function openDatabaseExport() {
const node = props.node;
if (!node.connectionId || !node.database) return;
connectionStore.databaseExportSource = {
connectionId: node.connectionId,
database: node.database,
schema: node.type === "schema" ? node.schema : undefined,
};
}
function openTableImport() {
const node = props.node;
if (node.type !== "table" || !node.connectionId || !node.database) return;
@@ -1706,9 +1581,8 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<ContextMenuItem @click="openDataCompare">
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuItem :disabled="isExportingDatabase" @click="exportDatabase">
<Loader2 v-if="isExportingDatabase" class="w-4 h-4 animate-spin" />
<Download v-else class="w-4 h-4" />
<ContextMenuItem @click="openDatabaseExport">
<Download class="w-4 h-4" />
{{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<template v-if="canDropDatabase || canDropSchema">
+21
View File
@@ -13,6 +13,7 @@ const showTableImportDialog = ref(false);
const showStructureEditorDialog = ref(false);
const showFieldLineageDialog = ref(false);
const showDatabaseSearchDialog = ref(false);
const showDatabaseExportDialog = ref(false);
const showImportLayoutConfirm = ref(false);
const pendingImportLayout = ref<SidebarLayout | null>(null);
const showConfigPassphraseDialog = ref(false);
@@ -51,6 +52,9 @@ const lineagePrefillColumn = ref("");
const databaseSearchPrefillConnectionId = ref("");
const databaseSearchPrefillDatabase = ref("");
const databaseSearchPrefillSchema = ref("");
const databaseExportPrefillConnectionId = ref("");
const databaseExportPrefillDatabase = ref("");
const databaseExportPrefillSchema = ref("");
let watchersRegistered = false;
@@ -183,6 +187,19 @@ export function useDialogSources() {
}
},
);
watch(
() => connectionStore.databaseExportSource,
(v) => {
if (v) {
databaseExportPrefillConnectionId.value = v.connectionId;
databaseExportPrefillDatabase.value = v.database;
databaseExportPrefillSchema.value = v.schema ?? "";
showDatabaseExportDialog.value = true;
connectionStore.databaseExportSource = null;
}
},
);
} // end watchersRegistered
// Config export/import helpers
@@ -258,6 +275,7 @@ export function useDialogSources() {
showStructureEditorDialog,
showFieldLineageDialog,
showDatabaseSearchDialog,
showDatabaseExportDialog,
showImportLayoutConfirm,
pendingImportLayout,
showConfigPassphraseDialog,
@@ -295,6 +313,9 @@ export function useDialogSources() {
databaseSearchPrefillConnectionId,
databaseSearchPrefillDatabase,
databaseSearchPrefillSchema,
databaseExportPrefillConnectionId,
databaseExportPrefillDatabase,
databaseExportPrefillSchema,
onExportClick,
onExportConfirm,
onImportClick,
+13
View File
@@ -976,4 +976,17 @@ export default {
openSource: "Open-source repository",
officialDocs: "Official docs",
},
databaseExport: {
title: "Export Database",
includeStructure: "Table structure (DDL)",
includeData: "Table data (INSERT)",
includeObjects: "Views / Procedures / Functions",
export: "Export",
exporting: "Exporting...",
currentTable: "Current: {table} ({current}/{total})",
rowsExported: "{count} rows exported",
exportSuccess: "Database exported successfully",
exportError: "Export failed: {error}",
exportCancelled: "Export cancelled",
},
};
+13
View File
@@ -978,4 +978,17 @@ export default {
openSource: "Repositorio de código abierto",
officialDocs: "Documentación oficial",
},
databaseExport: {
title: "Exportar base de datos",
includeStructure: "Estructura de tablas (DDL)",
includeData: "Datos de tablas (INSERT)",
includeObjects: "Vistas / Procedimientos / Funciones",
export: "Exportar",
exporting: "Exportando...",
currentTable: "Actual: {table} ({current}/{total})",
rowsExported: "{count} filas exportadas",
exportSuccess: "Base de datos exportada exitosamente",
exportError: "Error al exportar: {error}",
exportCancelled: "Exportación cancelada",
},
};
+13
View File
@@ -961,4 +961,17 @@ export default {
openSource: "开源仓库",
officialDocs: "官方文档",
},
databaseExport: {
title: "导出数据库",
includeStructure: "表结构 (DDL)",
includeData: "表数据 (INSERT)",
includeObjects: "视图 / 存储过程 / 函数",
export: "导出",
exporting: "正在导出...",
currentTable: "当前: {table} ({current}/{total})",
rowsExported: "已导出 {count} 行",
exportSuccess: "数据库导出成功",
exportError: "导出失败: {error}",
exportCancelled: "导出已取消",
},
};
+6
View File
@@ -98,6 +98,10 @@ export const previewTableImportFile = forward("previewTableImportFile");
export const importTableFile = forward("importTableFile");
export const cancelTableImport = forward("cancelTableImport");
// Database Export
export const exportDatabaseSql = forward("exportDatabaseSql");
export const cancelDatabaseExport = forward("cancelDatabaseExport");
// Redis
export const redisListDatabases = forward("redisListDatabases");
export const redisScanKeys = forward("redisScanKeys");
@@ -172,4 +176,6 @@ export type {
TableImportRequest,
TableImportSummary,
TableImportProgress,
DatabaseExportRequest,
ExportProgress,
} from "./tauri";
+40
View File
@@ -38,6 +38,8 @@ import type {
TableImportRequest,
TableImportSummary,
TableImportProgress,
DatabaseExportRequest,
ExportProgress,
} from "./tauri";
// ---------------------------------------------------------------------------
@@ -497,6 +499,44 @@ export async function cancelTableImport(importId: string): Promise<boolean> {
return post("/api/import/cancel", { importId });
}
// ---------------------------------------------------------------------------
// Database Export
// ---------------------------------------------------------------------------
export async function exportDatabaseSql(
request: DatabaseExportRequest,
onProgress: (progress: ExportProgress) => void,
): Promise<void> {
// 1. POST to start the export
const res = await fetch("/api/export/database", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request }),
});
if (!res.ok) throw new Error(await res.text());
// 2. SSE to listen for progress
return new Promise((resolve, reject) => {
const es = new EventSource(`/api/export/database/progress/${request.exportId}`);
es.onmessage = (e) => {
const progress: ExportProgress = JSON.parse(e.data);
onProgress(progress);
if (progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled") {
es.close();
resolve();
}
};
es.onerror = () => {
es.close();
reject(new Error("Export SSE connection failed"));
};
});
}
export async function cancelDatabaseExport(exportId: string): Promise<void> {
await post("/api/export/database/cancel", { exportId });
}
// ---------------------------------------------------------------------------
// Redis
// ---------------------------------------------------------------------------
+48
View File
@@ -744,3 +744,51 @@ export async function importTableFile(
export async function cancelTableImport(importId: string): Promise<boolean> {
return invoke("cancel_table_import", { importId });
}
// --- Database Export ---
export interface DatabaseExportRequest {
exportId: string;
connectionId: string;
database: string;
schema: string;
filePath: string;
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;
batchSize: number;
}
export interface ExportProgress {
exportId: string;
currentObject: string;
objectIndex: number;
totalObjects: number;
rowsExported: number;
totalRows: number | null;
status: "Running" | "Done" | "Error" | "Cancelled";
error: string | null;
}
export async function exportDatabaseSql(
request: DatabaseExportRequest,
onProgress: (progress: ExportProgress) => void,
): Promise<void> {
const unlisten: UnlistenFn = await listen<ExportProgress>("database-export-progress", (event) => {
if (event.payload.exportId === request.exportId) {
onProgress(event.payload);
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
unlisten();
}
}
});
try {
await invoke("export_database_sql", { request });
} catch (e) {
unlisten();
throw e;
}
}
export async function cancelDatabaseExport(exportId: string): Promise<void> {
await invoke("cancel_database_export", { exportId });
}
+6
View File
@@ -91,6 +91,11 @@ export const useConnectionStore = defineStore("connection", () => {
database: string;
schema?: string;
} | null>(null);
const databaseExportSource = ref<{
connectionId: string;
database: string;
schema?: string;
} | null>(null);
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1400,6 +1405,7 @@ export const useConnectionStore = defineStore("connection", () => {
structureEditorSource,
fieldLineageSource,
databaseSearchSource,
databaseExportSource,
createConnectionGroup(name: string) {
const result = createGroupOp(sidebarLayout.value, name);
updateLayoutAndRebuild(result.layout);