dbx/apps/desktop/src/components/export/DatabaseExportDialog.vue

964 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, 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 ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import * as api from "@/lib/backend/api";
import type { ExportProgress } from "@/lib/backend/api";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal, runWithDatabaseBackupSnapshot, shouldUseDatabaseBackupSnapshot, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
import { buildSelectedTablesPayload } from "@/lib/export/databaseExportSelection";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Input } from "@/components/ui/input";
import { Download, Square, CheckSquare, Search, X, Loader2 } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
const { t } = useI18n();
const { toast } = useToast();
const { addDatabaseExportTask, updateDatabaseExportTask } = useExportTracker();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
prefillTables?: string[];
prefillAllDatabases?: boolean;
}>();
// Connection / Database / Schema selectors
const connectionId = ref("");
const database = ref("");
const databases = ref<string[]>([]);
const selectedDatabases = ref<string[]>([]);
const databaseFilter = ref("");
const schema = ref("");
const schemas = ref<string[]>([]);
const loadingMeta = ref(false);
const tables = ref<string[]>([]);
const selectedTables = ref<string[]>([]);
const loadingTables = ref(false);
const tableFilter = ref("");
const filteredTables = computed(() => {
const q = tableFilter.value.trim().toLowerCase();
if (!q) return tables.value;
return tables.value.filter((name) => name.toLowerCase().includes(q));
});
const tableError = ref<string | null>(null);
// Options
const includeStructure = ref(true);
const includeData = ref(true);
const includeObjects = ref(true);
const includeCreateDatabase = ref(false);
const dropTableIfExists = ref(false);
const omitAutoIncrement = ref(false);
// `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on
// db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode).
const isMysqlFamily = computed(() => store.getConfig(connectionId.value)?.db_type === "mysql");
// 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 exportStartedAt = ref<number | null>(null);
const exportFinishedAt = ref<number | null>(null);
const currentTime = ref(Date.now());
const pendingPrefillTable = ref("");
const pendingPrefillTables = ref<string[]>([]);
const exportAllDatabases = ref(false);
const batchDatabaseIndex = ref(0);
const batchDatabaseTotal = ref(0);
const batchRowsExported = ref(0);
const activeDatabaseExportId = ref("");
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
function finishExportTiming() {
exportFinishedAt.value ??= Date.now();
}
const exportElapsedText = computed(() => {
if (exportStartedAt.value === null) return "";
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
});
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
const canExport = computed(() => {
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
if (!connectionId.value || !hasContent || isExporting.value) return false;
if (exportAllDatabases.value) return selectedDatabases.value.length > 0 && !loadingMeta.value;
return database.value && schema.value && !loadingTables.value && !tableError.value && (tables.value.length === 0 || selectedTables.value.length > 0);
});
const selectedTableSet = computed(() => new Set(selectedTables.value));
const selectedDatabaseSet = computed(() => new Set(selectedDatabases.value));
const filteredDatabases = computed(() => {
const q = databaseFilter.value.trim().toLowerCase();
if (!q) return databases.value;
return databases.value.filter((name) => name.toLowerCase().includes(q));
});
function connectionIconType(connId: string) {
const config = store.getConfig(connId);
return config?.driver_profile || config?.db_type || "mysql";
}
// 当前连接是否为单数据库架构达梦、Oracle 等),这类数据库的"源数据库"与"Schema"没有层级关系
const isSingleDb = computed(() => {
if (!connectionId.value) return false;
const config = store.getConfig(connectionId.value);
return isSingleDatabase(config?.db_type);
});
function sanitizeFileName(value: string): string {
return (value || "database").replace(/[\\/:*?"<>|]+/g, "_").trim() || "database";
}
function joinExportPath(directory: string, fileName: string): string {
const separator = directory.includes("\\") ? "\\" : "/";
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
async function loadDatabases(connId: string) {
if (!connId) return;
loadingMeta.value = true;
try {
await store.ensureConnected(connId);
const config = store.getConfig(connId);
let names: string[];
if (config?.db_type === "dameng") {
// 达梦的"数据库"概念对应 schema使用 fetchNamespaceOptionsForConnection
// 内部已正确处理达梦:通过 listSchemas 获取 schema 列表而非用户列表
names = await fetchNamespaceOptionsForConnection(connId, config);
} else {
const dbs = await api.listDatabases(connId);
names = databaseOptionsForConnection(
dbs.map((d) => d.name),
config,
);
}
databases.value = names;
selectedDatabases.value = exportAllDatabases.value ? [...names] : [];
tables.value = [];
selectedTables.value = [];
// 单数据库架构下达梦、Oracle 等databases 实际是 schema 列表,
// 直接作为 schemas 使用,并将 database 初始值设为第一个 schema
// 这样做的原因是:单数据库架构中"源数据库"与"Schema"没有层级关系,
// 所有 schema 都在同一个数据库实例中,因此 schema 同时作为 database 参数
if (config?.db_type && isSingleDatabase(config.db_type)) {
schemas.value = names;
schema.value = names.length === 1 ? names[0] : "";
database.value = schema.value;
} else {
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 loadTables(preferredTable = "", preferredTables: string[] = []) {
if (!connectionId.value || !database.value || !schema.value) return;
loadingTables.value = true;
tableError.value = null;
tables.value = [];
selectedTables.value = [];
try {
const tableInfos = await api.listTables(connectionId.value, database.value, schema.value);
const names = tableInfos.map((table) => table.name);
tables.value = names;
const preferredSet = new Set(preferredTables.filter((name) => names.includes(name)));
selectedTables.value = preferredSet.size > 0 ? names.filter((name) => preferredSet.has(name)) : preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
} catch (e: any) {
tableError.value = e?.message || String(e);
} finally {
loadingTables.value = false;
}
}
function toggleTable(table: string) {
const selected = new Set(selectedTables.value);
if (selected.has(table)) {
selected.delete(table);
} else {
selected.add(table);
}
selectedTables.value = tables.value.filter((name) => selected.has(name));
}
function selectAllTables() {
const selected = new Set(selectedTables.value);
for (const name of filteredTables.value) selected.add(name);
selectedTables.value = tables.value.filter((name) => selected.has(name));
}
function clearSelectedTables() {
const removing = new Set(filteredTables.value);
selectedTables.value = selectedTables.value.filter((name) => !removing.has(name));
}
function toggleDatabase(db: string) {
const selected = new Set(selectedDatabases.value);
if (selected.has(db)) {
selected.delete(db);
} else {
selected.add(db);
}
selectedDatabases.value = databases.value.filter((name) => selected.has(name));
}
function selectAllDatabases() {
const selected = new Set(selectedDatabases.value);
for (const name of filteredDatabases.value) selected.add(name);
selectedDatabases.value = databases.value.filter((name) => selected.has(name));
}
function clearSelectedDatabases() {
const removing = new Set(filteredDatabases.value);
selectedDatabases.value = selectedDatabases.value.filter((name) => !removing.has(name));
}
async function buildExportPlanForDatabases(dbs: string[]): Promise<AllDatabaseExportPlanItem[]> {
const config = store.getConfig(connectionId.value);
const dbType = config?.db_type;
const schemaAware = isSchemaAware(dbType);
const schemasByDatabase: Record<string, string[]> = {};
if (schemaAware) {
for (const db of dbs) {
schemasByDatabase[db] = await api.listSchemas(connectionId.value, db);
}
}
return buildAllDatabaseExportPlan({ databases: dbs, schemaAware, schemasByDatabase, dbType });
}
async function startExport() {
if (!canExport.value) return;
if (exportAllDatabases.value) {
await startAllDatabasesExport();
return;
}
exportId.value = generateDatabaseExportId();
let filePath = "";
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const safeName = sanitizeFileName(database.value || "database");
const path = await save({
defaultPath: `${safeName}.sql`,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) return;
filePath = path;
} catch (e: any) {
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`;
}
// Switch to the progress view only after the save dialog closes, and seed a
// preparing state so the dialog is never a blank panel while metadata loads.
isExporting.value = true;
exportStartedAt.value = Date.now();
exportFinishedAt.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportProgress.value = {
exportId: exportId.value,
currentObject: "",
objectIndex: 0,
totalObjects: 0,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: true,
};
addDatabaseExportTask(exportId.value, database.value || "database", filePath);
try {
const connectionType = store.getConfig(connectionId.value)?.db_type;
await runWithDatabaseBackupSnapshot(
{
connectionId: connectionId.value,
database: database.value,
enabled: shouldUseDatabaseBackupSnapshot(connectionType, includeData.value, isTauriRuntime()),
},
async (snapshotSessionId) => {
const request: api.DatabaseExportRequest = {
exportId: exportId.value,
connectionId: connectionId.value,
database: database.value,
schema: schema.value,
filePath,
selectedTables: buildSelectedTablesPayload(tables.value, selectedTables.value),
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
};
return runDatabaseExportUntilTerminal(request, (progress) => {
exportProgress.value = { ...progress };
updateDatabaseExportTask(progress.exportId, progress);
if (progress.status === "Done") {
finishExportTiming();
exportDone.value = true;
isExporting.value = false;
toast(t("databaseExport.exportSuccess"), 3000);
} else if (progress.status === "Error") {
finishExportTiming();
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
finishExportTiming();
exportCancelled.value = true;
isExporting.value = false;
}
});
},
(terminal) => terminal.status === "Done",
);
} catch (e: any) {
exportError.value = e?.message || String(e);
const lastProgress = exportProgress.value as api.ExportProgress | null;
const fallbackProgress: api.ExportProgress = {
exportId: exportId.value,
currentObject: database.value || "database",
objectIndex: lastProgress?.objectIndex ?? 0,
totalObjects: lastProgress?.totalObjects ?? 0,
rowsExported: lastProgress?.rowsExported ?? 0,
totalRows: lastProgress?.totalRows ?? null,
status: "Error",
error: exportError.value,
};
updateDatabaseExportTask(exportId.value, fallbackProgress);
finishExportTiming();
isExporting.value = false;
}
}
async function startAllDatabasesExport() {
if (!canExport.value) return;
let directoryPath = "";
if (isTauriRuntime()) {
try {
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const path = await openDialog({
directory: true,
multiple: false,
title: t("databaseExport.selectExportDirectory"),
});
if (!path || Array.isArray(path)) return;
directoryPath = path;
} catch (e: any) {
toast(e?.message || String(e), 5000);
return;
}
}
isExporting.value = true;
exportStartedAt.value = Date.now();
exportFinishedAt.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
batchDatabaseIndex.value = 0;
batchRowsExported.value = 0;
const dbs = [...selectedDatabases.value];
const connectionType = store.getConfig(connectionId.value)?.db_type;
const batchId = generateDatabaseExportId();
exportId.value = batchId;
exportProgress.value = {
exportId: batchId,
currentObject: "",
objectIndex: 0,
totalObjects: 0,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: true,
};
addDatabaseExportTask(batchId, t("databaseExport.allDatabasesTask", { count: dbs.length }), directoryPath);
let exportPlan: AllDatabaseExportPlanItem[] = [];
try {
exportPlan = await buildExportPlanForDatabases(dbs);
batchDatabaseTotal.value = exportPlan.length;
exportProgress.value = {
exportId: batchId,
currentObject: "",
objectIndex: 0,
totalObjects: exportPlan.length,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: true,
};
for (let index = 0; index < exportPlan.length; index += 1) {
if (exportCancelled.value) break;
const item = exportPlan[index]!;
batchDatabaseIndex.value = index + 1;
const currentExportId = `${batchId}-${index + 1}`;
activeDatabaseExportId.value = currentExportId;
const filePath = isTauriRuntime() ? joinExportPath(directoryPath, `${sanitizeFileName(item.fileStem)}.sql`) : `__web_export_${currentExportId}.sql`;
let currentDatabaseRowsExported = 0;
await runWithDatabaseBackupSnapshot(
{
connectionId: connectionId.value,
database: item.database,
enabled: shouldUseDatabaseBackupSnapshot(connectionType, includeData.value, isTauriRuntime()),
},
(snapshotSessionId) =>
runDatabaseExportUntilTerminal(
{
exportId: currentExportId,
connectionId: connectionId.value,
database: item.database,
schema: item.schema,
filePath,
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
},
(progress) => {
const nextRowsExported = Math.max(0, progress.rowsExported);
batchRowsExported.value += Math.max(0, nextRowsExported - currentDatabaseRowsExported);
currentDatabaseRowsExported = nextRowsExported;
exportProgress.value = {
...progress,
exportId: batchId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
rowsExported: batchRowsExported.value,
};
updateDatabaseExportTask(batchId, {
...progress,
exportId: batchId,
currentObject: item.displayName,
objectIndex: index,
totalObjects: exportPlan.length,
rowsExported: batchRowsExported.value,
});
if (progress.status === "Error") {
finishExportTiming();
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
finishExportTiming();
exportCancelled.value = true;
isExporting.value = false;
}
},
),
(terminal) => terminal.status === "Done",
);
if (exportError.value || exportCancelled.value) break;
activeDatabaseExportId.value = "";
}
if (!exportError.value && !exportCancelled.value) {
exportDone.value = true;
finishExportTiming();
isExporting.value = false;
const finalProgress: api.ExportProgress = {
exportId: batchId,
currentObject: t("databaseExport.allDatabasesTask", { count: dbs.length }),
objectIndex: exportPlan.length,
totalObjects: exportPlan.length,
rowsExported: batchRowsExported.value,
totalRows: null,
status: "Done",
error: null,
};
exportProgress.value = finalProgress;
updateDatabaseExportTask(batchId, finalProgress);
toast(t("databaseExport.exportAllSuccess", { count: dbs.length }), 3000);
}
} catch (e: any) {
exportError.value = e?.message || String(e);
updateDatabaseExportTask(batchId, {
exportId: batchId,
currentObject: t("databaseExport.allDatabasesTask", { count: dbs.length }),
objectIndex: Math.max(0, batchDatabaseIndex.value - 1),
totalObjects: batchDatabaseTotal.value || dbs.length,
rowsExported: batchRowsExported.value,
totalRows: null,
status: "Error",
error: exportError.value,
});
finishExportTiming();
isExporting.value = false;
}
}
async function cancelExport() {
if (exportId.value) {
if (exportAllDatabases.value) {
exportCancelled.value = true;
finishExportTiming();
isExporting.value = false;
if (activeDatabaseExportId.value) {
await api.cancelDatabaseExport(activeDatabaseExportId.value);
}
} else {
await api.cancelDatabaseExport(exportId.value);
}
}
}
function resetState() {
connectionId.value = "";
database.value = "";
databases.value = [];
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
pendingPrefillTable.value = "";
pendingPrefillTables.value = [];
exportAllDatabases.value = false;
selectedDatabases.value = [];
databaseFilter.value = "";
includeStructure.value = true;
includeData.value = true;
includeObjects.value = true;
includeCreateDatabase.value = false;
dropTableIfExists.value = false;
omitAutoIncrement.value = false;
isExporting.value = false;
exportProgress.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportStartedAt.value = null;
exportFinishedAt.value = null;
exportId.value = "";
batchDatabaseIndex.value = 0;
batchDatabaseTotal.value = 0;
batchRowsExported.value = 0;
activeDatabaseExportId.value = "";
}
const progressPercent = computed(() => {
if (exportAllDatabases.value && batchDatabaseTotal.value > 0) {
if (exportDone.value) return 100;
const current = exportProgress.value;
const currentDatabaseProgress = current && !current.preparing && current.totalObjects > 0 ? current.objectIndex / current.totalObjects : 0;
return Math.round(Math.min(1, (Math.max(0, batchDatabaseIndex.value - 1) + currentDatabaseProgress) / batchDatabaseTotal.value) * 100);
}
const p = exportProgress.value;
if (!p || p.preparing || p.totalObjects === 0) return 0;
return Math.round((p.objectIndex / p.totalObjects) * 100);
});
/** True while schema metadata is still loading — before objects are written. */
const isPreparingExport = computed(() => {
if (!isExporting.value) return false;
if (exportDone.value || exportError.value || exportCancelled.value) return false;
const p = exportProgress.value;
if (!p) return true;
return !!p.preparing || p.totalObjects <= 0;
});
const progressStatusText = computed(() => {
const p = exportProgress.value;
if (isPreparingExport.value) {
// Keep it as presence feedback only — no second progress counter that later resets.
if (p?.currentObject) {
return t("databaseExport.preparingObject", { object: p.currentObject });
}
return t("databaseExport.preparing");
}
if (!p) return t("databaseExport.exporting");
return t("databaseExport.currentTable", {
table: p.currentObject,
current: p.objectIndex,
total: p.totalObjects,
});
});
const skipConnectionWatch = ref(false);
watch(connectionId, (id) => {
if (skipConnectionWatch.value) {
skipConnectionWatch.value = false;
return;
}
database.value = "";
databases.value = [];
selectedDatabases.value = [];
schemas.value = [];
schema.value = "";
tables.value = [];
selectedTables.value = [];
tableError.value = null;
loadDatabases(id);
});
watch(database, (db) => {
if (exportAllDatabases.value) return;
// 单数据库架构下,"源数据库"由 schema 驱动,跳过此处的 schema 重新加载
if (isSingleDb.value) return;
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
if (db) loadSchemas(props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(schema, (value) => {
if (exportAllDatabases.value) return;
// 单数据库架构下schema 即 database同步 database 值
if (isSingleDb.value) {
database.value = value;
}
tables.value = [];
selectedTables.value = [];
tableError.value = null;
const preferredTable = pendingPrefillTable.value;
const preferredTables = pendingPrefillTables.value;
pendingPrefillTable.value = "";
pendingPrefillTables.value = [];
if (value) loadTables(preferredTable, preferredTables).catch((e) => toast(String(e), 5000));
});
watch(
open,
async (val) => {
if (val) {
resetState();
exportAllDatabases.value = props.prefillAllDatabases ?? false;
pendingPrefillTable.value = props.prefillTable ?? "";
pendingPrefillTables.value = props.prefillTables ?? [];
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);
}
}
}
},
{ immediate: true },
);
</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 position="popper" align="start">
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5 shrink-0" />
<ConnectionGroupBadge :connection-id="c.id" />
<span class="min-w-0 flex-1 truncate">{{ c.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="exportAllDatabases && databases.length" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">{{ t("databaseExport.databaseSelection") }}</Label>
<div class="text-[11px] text-muted-foreground">
{{ t("databaseExport.selectedDatabases", { selected: selectedDatabases.length, total: databases.length }) }}
</div>
</div>
<div class="space-y-2 rounded border border-border/60 p-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="databaseFilter" class="h-7 pl-7 text-xs" :placeholder="t('databaseExport.filterDatabases')" />
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="selectAllDatabases">
{{ t("databaseExport.selectAllDatabases") }}
</Button>
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="clearSelectedDatabases">
{{ t("databaseExport.clearDatabases") }}
</Button>
</div>
<div class="max-h-44 overflow-auto space-y-1 pr-1">
<button v-for="db in filteredDatabases" :key="db" type="button" class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted" @click="toggleDatabase(db)">
<CheckSquare v-if="selectedDatabaseSet.has(db)" 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" />
<span class="truncate">{{ db }}</span>
</button>
</div>
</div>
</div>
<div v-else-if="databases.length && !isSingleDb" 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 position="popper" align="start">
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="!exportAllDatabases && 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 position="popper" align="start">
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="!exportAllDatabases && schema" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">{{ t("databaseExport.tableSelection") }}</Label>
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
{{ t("databaseExport.selectedTables", { selected: selectedTables.length, total: tables.length }) }}
</div>
</div>
<div v-if="loadingTables" class="text-xs text-muted-foreground">
{{ t("databaseExport.loadingTables") }}
</div>
<div v-else-if="tableError" class="text-xs text-destructive">
{{ t("databaseExport.tableLoadError", { error: tableError }) }}
</div>
<div v-else-if="tables.length" class="space-y-2 rounded border border-border/60 p-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tableFilter" class="h-7 pl-7 text-xs" :placeholder="t('databaseExport.filterTables')" />
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="selectAllTables">
{{ t("databaseExport.selectAllTables") }}
</Button>
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="clearSelectedTables">
{{ t("databaseExport.clearTables") }}
</Button>
</div>
<div class="max-h-40 overflow-auto space-y-1 pr-1">
<button v-for="table in filteredTables" :key="table" type="button" class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted" @click="toggleTable(table)">
<CheckSquare v-if="selectedTableSet.has(table)" 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" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
{{ t("databaseExport.noTables") }}
</div>
</div>
<!-- Options -->
<div class="space-y-2.5 pt-1">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("databaseExport.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 v-if="isMysqlFamily" class="flex items-center gap-2 cursor-pointer text-xs" @click="includeCreateDatabase = !includeCreateDatabase">
<CheckSquare v-if="includeCreateDatabase" 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.includeCreateDatabase") }}
</div>
<div class="flex items-center gap-2 text-xs" :class="includeStructure ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'" @click="includeStructure && (dropTableIfExists = !dropTableIfExists)">
<CheckSquare v-if="dropTableIfExists && 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.dropTableIfExists") }}
</div>
<div v-if="includeStructure && isMysqlFamily" class="flex items-center gap-2 cursor-pointer text-xs" @click="omitAutoIncrement = !omitAutoIncrement">
<CheckSquare v-if="omitAutoIncrement" 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.omitAutoIncrement") }}
</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="exportAllDatabases && batchDatabaseTotal" class="text-xs text-muted-foreground">
{{ t("databaseExport.currentDatabase", { current: batchDatabaseIndex, total: batchDatabaseTotal }) }}
</div>
<div class="space-y-2">
<div v-if="!exportAllDatabases || !exportDone" class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 v-if="isExporting && !exportDone && !exportError && !exportCancelled" class="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
<span>{{ progressStatusText }}</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div v-if="isPreparingExport" class="database-export-progress-indeterminate h-full rounded-full bg-primary" />
<div v-else class="h-full rounded-full transition-[width] duration-300" :class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : exportDone ? 'bg-green-500' : 'bg-primary'" :style="{ width: `${exportDone ? 100 : progressPercent}%` }" />
</div>
<div v-if="exportProgress && !isPreparingExport" class="text-xs text-muted-foreground">
{{ exportAllDatabases ? t("databaseExport.allRowsExported", { count: exportProgress.rowsExported.toLocaleString() }) : t("databaseExport.rowsExported", { current: exportProgress.objectIndex, total: exportProgress.totalObjects, count: exportProgress.rowsExported.toLocaleString() }) }}
</div>
<div v-if="exportElapsedText" class="text-xs text-muted-foreground tabular-nums">
{{ t("exportProgress.elapsed", { duration: exportElapsedText }) }}
</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" />
{{ exportAllDatabases ? t("databaseExport.exportAllDatabases") : t("databaseExport.export") }}
</Button>
</template>
<template v-else-if="isExporting">
<Button variant="outline" size="sm" @click="open = false">
{{ t("databaseExport.runInBackground") }}
</Button>
<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>
<style scoped>
.database-export-progress-indeterminate {
width: 42%;
animation: database-export-progress-slide 1.15s ease-in-out infinite;
}
@keyframes database-export-progress-slide {
0% {
transform: translateX(-110%);
}
50% {
transform: translateX(70%);
}
100% {
transform: translateX(250%);
}
}
</style>