mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
feat: import table data from files
This commit is contained in:
+25
-1
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode, Settings } from "lucide-vue-next";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode, Settings, Sparkles } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,6 +38,7 @@ import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue";
|
||||
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
|
||||
import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue";
|
||||
import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue";
|
||||
import TableImportDialog from "@/components/import/TableImportDialog.vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -122,6 +123,7 @@ const showTransferDialog = ref(false);
|
||||
const showSchemaDiffDialog = ref(false);
|
||||
const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const transferPrefillConnectionId = ref("");
|
||||
const transferPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
@@ -132,6 +134,10 @@ const diagramPrefillConnectionId = ref("");
|
||||
const diagramPrefillDatabase = ref("");
|
||||
const diagramPrefillSchema = ref("");
|
||||
const diagramFocusTableName = ref("");
|
||||
const tableImportPrefillConnectionId = ref("");
|
||||
const tableImportPrefillDatabase = ref("");
|
||||
const tableImportPrefillSchema = ref("");
|
||||
const tableImportPrefillTable = ref("");
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
@@ -197,6 +203,17 @@ watch(() => connectionStore.diagramSource, (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => connectionStore.tableImportSource, (v) => {
|
||||
if (v) {
|
||||
tableImportPrefillConnectionId.value = v.connectionId;
|
||||
tableImportPrefillDatabase.value = v.database;
|
||||
tableImportPrefillSchema.value = v.schema ?? "";
|
||||
tableImportPrefillTable.value = v.tableName;
|
||||
showTableImportDialog.value = true;
|
||||
connectionStore.tableImportSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onConnectionConnectStarted(name: string) {
|
||||
toast(t("connection.connecting", { name }), 30000);
|
||||
}
|
||||
@@ -1257,6 +1274,13 @@ async function setupFileDrop() {
|
||||
:prefill-schema="diagramPrefillSchema"
|
||||
:focus-table-name="diagramFocusTableName"
|
||||
/>
|
||||
<TableImportDialog
|
||||
v-model:open="showTableImportDialog"
|
||||
:prefill-connection-id="tableImportPrefillConnectionId"
|
||||
:prefill-database="tableImportPrefillDatabase"
|
||||
:prefill-schema="tableImportPrefillSchema"
|
||||
:prefill-table="tableImportPrefillTable"
|
||||
/>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Check, FileUp, Loader2, Square, Upload, X,
|
||||
} from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { autoMapImportColumns } from "@/lib/tableImport";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTable?: string;
|
||||
}>();
|
||||
|
||||
const SKIP_VALUE = "__skip__";
|
||||
const targetColumns = ref<ColumnInfo[]>([]);
|
||||
const preview = ref<api.TableImportPreview | null>(null);
|
||||
const columnMapping = ref<Record<string, string>>({});
|
||||
const loadingTarget = ref(false);
|
||||
const loadingPreview = ref(false);
|
||||
const importMode = ref<api.TableImportMode>("append");
|
||||
const batchSize = ref(500);
|
||||
const running = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const importId = ref("");
|
||||
const progress = ref<api.TableImportProgress | null>(null);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined
|
||||
);
|
||||
const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name));
|
||||
const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
||||
const currentPreview = preview.value;
|
||||
if (!currentPreview) return [];
|
||||
return currentPreview.columns
|
||||
.map((sourceColumn) => ({
|
||||
sourceColumn,
|
||||
targetColumn: columnMapping.value[sourceColumn] ?? "",
|
||||
}))
|
||||
.filter((mapping) => mapping.targetColumn);
|
||||
});
|
||||
const mappedCount = computed(() => mappedColumns.value.length);
|
||||
const canImport = computed(() =>
|
||||
!!preview.value &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable &&
|
||||
mappedColumns.value.length > 0 &&
|
||||
!running.value
|
||||
);
|
||||
const progressPercent = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p || p.totalRows <= 0) return 0;
|
||||
return Math.min(100, Math.round((p.rowsImported / p.totalRows) * 100));
|
||||
});
|
||||
const targetLabel = computed(() => {
|
||||
const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable]
|
||||
.filter(Boolean);
|
||||
return pieces.join(" / ");
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
targetColumns.value = [];
|
||||
preview.value = null;
|
||||
columnMapping.value = {};
|
||||
importMode.value = "append";
|
||||
batchSize.value = 500;
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
importId.value = "";
|
||||
progress.value = null;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
function applyAutoMapping() {
|
||||
const currentPreview = preview.value;
|
||||
if (!currentPreview) return;
|
||||
columnMapping.value = autoMapImportColumns(currentPreview.columns, targetColumnNames.value);
|
||||
}
|
||||
|
||||
async function loadTargetColumns() {
|
||||
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return;
|
||||
loadingTarget.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
targetColumns.value = await api.getColumns(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema || props.prefillDatabase,
|
||||
props.prefillTable,
|
||||
);
|
||||
applyAutoMapping();
|
||||
} catch (e: any) {
|
||||
errorMessage.value = String(e?.message || e);
|
||||
} finally {
|
||||
loadingTarget.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Data files", extensions: ["csv", "tsv", "json", "xlsx", "xlsm", "xls"] },
|
||||
{ name: "CSV", extensions: ["csv", "tsv"] },
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
|
||||
],
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
|
||||
loadingPreview.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
preview.value = await api.previewTableImportFile(selected);
|
||||
applyAutoMapping();
|
||||
} catch (e: any) {
|
||||
preview.value = null;
|
||||
columnMapping.value = {};
|
||||
errorMessage.value = String(e?.message || e);
|
||||
} finally {
|
||||
loadingPreview.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMapping(sourceColumn: string, value: any) {
|
||||
const target = String(value);
|
||||
columnMapping.value = {
|
||||
...columnMapping.value,
|
||||
[sourceColumn]: target === SKIP_VALUE ? "" : target,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCell(value: unknown) {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
const currentPreview = preview.value;
|
||||
if (!canImport.value || !currentPreview || !props.prefillConnectionId || !props.prefillTable) return;
|
||||
running.value = true;
|
||||
cancelling.value = false;
|
||||
errorMessage.value = "";
|
||||
importId.value = crypto.randomUUID();
|
||||
progress.value = {
|
||||
importId: importId.value,
|
||||
status: "running",
|
||||
rowsImported: 0,
|
||||
totalRows: currentPreview.totalRows,
|
||||
};
|
||||
|
||||
try {
|
||||
const summary = await api.importTableFile({
|
||||
importId: importId.value,
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase || "",
|
||||
schema: props.prefillSchema || "",
|
||||
table: props.prefillTable,
|
||||
filePath: currentPreview.filePath,
|
||||
mappings: mappedColumns.value,
|
||||
mode: importMode.value,
|
||||
batchSize: Math.max(1, Number(batchSize.value) || 500),
|
||||
}, (nextProgress) => {
|
||||
progress.value = nextProgress;
|
||||
});
|
||||
toast(t("tableImport.success", { count: summary.rowsImported }), 2500);
|
||||
open.value = false;
|
||||
} catch (e: any) {
|
||||
errorMessage.value = String(e?.message || e);
|
||||
} finally {
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelImport() {
|
||||
if (!importId.value) return;
|
||||
cancelling.value = true;
|
||||
await api.cancelTableImport(importId.value);
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
resetState();
|
||||
void loadTargetColumns();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogScrollContent class="sm:max-w-[760px]" :trap-focus="false" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<FileUp class="h-4 w-4" />
|
||||
{{ t('tableImport.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div class="min-w-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t('tableImport.target') }}</div>
|
||||
<div class="truncate text-sm font-medium">{{ targetLabel || t('editor.noDatabase') }}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" :disabled="running || loadingPreview" @click="selectFile">
|
||||
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Upload v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('tableImport.selectFile') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.file') }}</div>
|
||||
<div class="truncate font-medium">{{ preview.fileName }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.rows') }}</div>
|
||||
<div class="font-medium">{{ preview.totalRows.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.mapped') }}</div>
|
||||
<div class="font-medium">{{ mappedCount }} / {{ preview.columns.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-[minmax(220px,280px)_1fr] gap-3">
|
||||
<div class="rounded-md border">
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t('tableImport.mapping') }}</div>
|
||||
<div class="max-h-[280px] overflow-auto p-2">
|
||||
<div
|
||||
v-for="sourceColumn in preview.columns"
|
||||
:key="sourceColumn"
|
||||
class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1"
|
||||
>
|
||||
<div class="truncate font-mono text-xs" :title="sourceColumn">{{ sourceColumn }}</div>
|
||||
<Select
|
||||
:model-value="columnMapping[sourceColumn] || SKIP_VALUE"
|
||||
@update:model-value="(value: any) => updateMapping(sourceColumn, value)"
|
||||
>
|
||||
<SelectTrigger class="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SKIP_VALUE">{{ t('tableImport.skipColumn') }}</SelectItem>
|
||||
<SelectItem v-for="column in targetColumns" :key="column.name" :value="column.name">
|
||||
{{ column.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 rounded-md border">
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t('tableImport.preview') }}</div>
|
||||
<div class="max-h-[280px] overflow-auto">
|
||||
<table class="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in preview.columns"
|
||||
:key="column"
|
||||
class="border-b border-r px-2 py-1.5 text-left font-medium"
|
||||
>
|
||||
<span class="block max-w-[140px] truncate">{{ column }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in preview.rows" :key="rowIndex">
|
||||
<td
|
||||
v-for="(cell, colIndex) in row"
|
||||
:key="colIndex"
|
||||
class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono"
|
||||
:class="{ 'text-muted-foreground': cell === null }"
|
||||
>
|
||||
<span class="block truncate">{{ formatCell(cell) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-3 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('tableImport.mode') }}</Label>
|
||||
<Select :model-value="importMode" @update:model-value="(value: any) => importMode = value">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">{{ t('tableImport.append') }}</SelectItem>
|
||||
<SelectItem value="truncate">{{ t('tableImport.truncate') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.batchSize') }}</Label>
|
||||
<Input v-model.number="batchSize" type="number" min="1" class="h-8 text-xs" />
|
||||
</div>
|
||||
<div v-if="running || progress" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('tableImport.progress') }}</Label>
|
||||
<div class="h-8 rounded-md border px-2 text-xs flex items-center gap-2">
|
||||
<Loader2 v-if="running && !cancelling" class="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<Square v-else-if="cancelling" class="h-3.5 w-3.5 fill-current text-destructive" />
|
||||
<Check v-else class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<span class="truncate">
|
||||
{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} · {{ progressPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingTarget" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="running" @click="open = false">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('dangerDialog.cancel') }}
|
||||
</Button>
|
||||
<Button v-if="running" variant="destructive" :disabled="cancelling" @click="cancelImport">
|
||||
<Loader2 v-if="cancelling" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="mr-1.5 h-3.5 w-3.5 fill-current" />
|
||||
{{ t('sqlFile.cancel') }}
|
||||
</Button>
|
||||
<Button v-else :disabled="!canImport" @click="startImport">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('tableImport.start') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
@@ -43,6 +43,7 @@ const props = defineProps<{
|
||||
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]);
|
||||
const isExportingDatabase = ref(false);
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
@@ -513,6 +514,17 @@ function openDiagram() {
|
||||
};
|
||||
}
|
||||
|
||||
function openTableImport() {
|
||||
const node = props.node;
|
||||
if (node.type !== "table" || !node.connectionId || !node.database) return;
|
||||
connectionStore.tableImportSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
const canExpand = !leafTypes.has(props.node.type);
|
||||
const canPin = computed(() => pinnableTypes.has(props.node.type));
|
||||
const canOpenSqlFileExecution = computed(() => {
|
||||
@@ -523,6 +535,10 @@ const canOpenDiagram = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return !!props.node.database && !!config && diagramSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const canOpenTableImport = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
|
||||
const hasTypeMenu = computed(() => {
|
||||
const t = props.node.type;
|
||||
@@ -685,6 +701,9 @@ async function showMore() {
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
|
||||
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport">
|
||||
<FileUp class="w-4 h-4" /> {{ t('contextMenu.importData') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
|
||||
@@ -226,6 +226,7 @@ export default {
|
||||
exportDatabase: "Export Database",
|
||||
exportDatabaseSuccess: "Exported {count} tables, up to {limit} rows each",
|
||||
exportDatabaseFailed: "Failed to export database: {message}",
|
||||
importData: "Import Data",
|
||||
exportData: "Export Data",
|
||||
exportStructure: "Export Structure",
|
||||
},
|
||||
@@ -349,6 +350,23 @@ export default {
|
||||
overallProgress: "Overall progress",
|
||||
dataTransfer: "Data Transfer",
|
||||
},
|
||||
tableImport: {
|
||||
title: "Import Table Data",
|
||||
target: "Target table",
|
||||
selectFile: "Select File",
|
||||
file: "File",
|
||||
rows: "Rows",
|
||||
mapped: "Mapped",
|
||||
mapping: "Column Mapping",
|
||||
skipColumn: "Skip",
|
||||
preview: "Preview",
|
||||
mode: "Import mode",
|
||||
append: "Append data",
|
||||
truncate: "Truncate then import",
|
||||
progress: "Progress",
|
||||
start: "Start Import",
|
||||
success: "Imported {count} rows",
|
||||
},
|
||||
sqlFile: {
|
||||
title: "Execute SQL File",
|
||||
file: "File",
|
||||
|
||||
@@ -226,6 +226,7 @@ export default {
|
||||
exportDatabase: "导出数据库",
|
||||
exportDatabaseSuccess: "已导出 {count} 张表,每表最多 {limit} 行",
|
||||
exportDatabaseFailed: "导出数据库失败:{message}",
|
||||
importData: "导入数据",
|
||||
exportData: "导出数据",
|
||||
exportStructure: "导出表结构",
|
||||
},
|
||||
@@ -349,6 +350,23 @@ export default {
|
||||
overallProgress: "整体进度",
|
||||
dataTransfer: "数据传输",
|
||||
},
|
||||
tableImport: {
|
||||
title: "导入表数据",
|
||||
target: "目标表",
|
||||
selectFile: "选择文件",
|
||||
file: "文件",
|
||||
rows: "行数",
|
||||
mapped: "已映射",
|
||||
mapping: "字段映射",
|
||||
skipColumn: "跳过",
|
||||
preview: "预览",
|
||||
mode: "导入模式",
|
||||
append: "追加数据",
|
||||
truncate: "清空后导入",
|
||||
progress: "进度",
|
||||
start: "开始导入",
|
||||
success: "已导入 {count} 行",
|
||||
},
|
||||
sqlFile: {
|
||||
title: "执行 SQL 文件",
|
||||
file: "文件",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const IMPORT_SKIP_TARGET = "";
|
||||
|
||||
export function normalizeImportColumnName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function autoMapImportColumns(
|
||||
sourceColumns: string[],
|
||||
targetColumns: string[],
|
||||
): Record<string, string> {
|
||||
const exactTargets = new Map(targetColumns.map((column) => [column, column]));
|
||||
const normalizedTargets = new Map(
|
||||
targetColumns.map((column) => [normalizeImportColumnName(column), column]),
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
sourceColumns.map((source) => [
|
||||
source,
|
||||
exactTargets.get(source) ?? normalizedTargets.get(normalizeImportColumnName(source)) ?? IMPORT_SKIP_TARGET,
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -409,3 +409,76 @@ export async function startTransfer(
|
||||
export async function cancelTransfer(transferId: string): Promise<void> {
|
||||
return invoke("cancel_transfer", { transferId });
|
||||
}
|
||||
|
||||
// --- Table File Import ---
|
||||
export type TableImportMode = "append" | "truncate";
|
||||
export type TableImportStatus = "running" | "done" | "error" | "cancelled";
|
||||
|
||||
export interface TableImportColumnMapping {
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
export interface TableImportPreview {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileType: string;
|
||||
sizeBytes: number;
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface TableImportRequest {
|
||||
importId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
filePath: string;
|
||||
mappings: TableImportColumnMapping[];
|
||||
mode: TableImportMode;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface TableImportSummary {
|
||||
importId: string;
|
||||
rowsImported: number;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface TableImportProgress {
|
||||
importId: string;
|
||||
status: TableImportStatus;
|
||||
rowsImported: number;
|
||||
totalRows: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export async function previewTableImportFile(filePath: string): Promise<TableImportPreview> {
|
||||
return invoke("preview_table_import_file", { filePath });
|
||||
}
|
||||
|
||||
export async function importTableFile(
|
||||
request: TableImportRequest,
|
||||
onProgress: (progress: TableImportProgress) => void,
|
||||
): Promise<TableImportSummary> {
|
||||
const unlisten: UnlistenFn = await listen<TableImportProgress>("table-import-progress", (event) => {
|
||||
if (event.payload.importId === request.importId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await invoke("import_table_file", { request });
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
return invoke("cancel_table_import", { importId });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null);
|
||||
const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
@@ -657,5 +658,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
schemaDiffSource,
|
||||
sqlFileSource,
|
||||
diagramSource,
|
||||
tableImportSource,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user