From 4666f8a5f2203923433cd007c11fad2b4e460e49 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 6 Jul 2026 00:00:57 +0800 Subject: [PATCH] feat(import): add table import wizard --- .../components/import/TableImportDialog.vue | 507 +++++++-- apps/desktop/src/i18n/locales/en.ts | 42 + apps/desktop/src/i18n/locales/es.ts | 42 + apps/desktop/src/i18n/locales/it.ts | 42 + apps/desktop/src/i18n/locales/ja.ts | 42 + apps/desktop/src/i18n/locales/pt-BR.ts | 42 + apps/desktop/src/i18n/locales/zh-CN.ts | 42 + apps/desktop/src/i18n/locales/zh-TW.ts | 42 + .../lib/__tests__/table/tableImport.spec.ts | 49 + apps/desktop/src/lib/backend/api.ts | 4 + apps/desktop/src/lib/backend/http.ts | 20 +- apps/desktop/src/lib/backend/tauri.ts | 33 +- apps/desktop/src/lib/table/tableImport.ts | 71 ++ crates/dbx-core/src/table_import.rs | 964 ++++++++++++++++-- crates/dbx-web/src/routes/table_import.rs | 126 ++- src-tauri/src/commands/table_import.rs | 8 +- 16 files changed, 1871 insertions(+), 205 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/table/tableImport.spec.ts diff --git a/apps/desktop/src/components/import/TableImportDialog.vue b/apps/desktop/src/components/import/TableImportDialog.vue index 257d12e79..0f05ccee2 100644 --- a/apps/desktop/src/components/import/TableImportDialog.vue +++ b/apps/desktop/src/components/import/TableImportDialog.vue @@ -8,10 +8,10 @@ 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"; +import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; -import { autoMapImportColumns } from "@/lib/table/tableImport"; +import { autoMapImportColumns, nextTableImportWizardStep, previousTableImportWizardStep, requiredImportTargetColumns, validateImportMappings, type TableImportWizardStep } from "@/lib/table/tableImport"; import type { ColumnInfo } from "@/types/database"; import * as api from "@/lib/backend/api"; @@ -29,6 +29,8 @@ const props = defineProps<{ const SKIP_VALUE = "__skip__"; const targetColumns = ref([]); +const selectedSource = ref(null); +const sourceFormat = ref("csv"); const preview = ref(null); const columnMapping = ref>({}); const loadingTarget = ref(false); @@ -40,7 +42,32 @@ const cancelling = ref(false); const importId = ref(""); const progress = ref(null); const errorMessage = ref(""); +const wizardStep = ref("source"); const fileInput = ref(null); +const delimiter = ref(","); +const hasHeader = ref(true); +const trimValues = ref(false); +const emptyStringAsNull = ref(true); +const selectedSheet = ref(""); +const jsonShape = ref("auto"); +const previewLimit = ref(50); +let previewReloadTimer: ReturnType | null = null; + +const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labelKey: string; descriptionKey: string }> = [ + { value: "csv", icon: FileText, labelKey: "tableImport.formatCsv", descriptionKey: "tableImport.formatCsvDescription" }, + { value: "tsv", icon: FileText, labelKey: "tableImport.formatTsv", descriptionKey: "tableImport.formatTsvDescription" }, + { value: "delimited", icon: FileText, labelKey: "tableImport.formatDelimited", descriptionKey: "tableImport.formatDelimitedDescription" }, + { value: "json", icon: FileJson, labelKey: "tableImport.formatJson", descriptionKey: "tableImport.formatJsonDescription" }, + { value: "excel", icon: FileSpreadsheet, labelKey: "tableImport.formatExcel", descriptionKey: "tableImport.formatExcelDescription" }, +]; + +const wizardSteps: Array<{ value: TableImportWizardStep; labelKey: string }> = [ + { value: "source", labelKey: "tableImport.stepSource" }, + { value: "options", labelKey: "tableImport.stepOptions" }, + { value: "mapping", labelKey: "tableImport.stepMapping" }, + { value: "review", labelKey: "tableImport.stepReview" }, + { value: "execution", labelKey: "tableImport.stepExecution" }, +]; const selectedConnection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined)); const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name)); @@ -55,7 +82,21 @@ const mappedColumns = computed(() => { .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 mappingValidation = computed(() => validateImportMappings(mappedColumns.value)); +const requiredUnmappedColumns = computed(() => + requiredImportTargetColumns( + targetColumns.value, + mappedColumns.value.map((mapping) => mapping.targetColumn), + ), +); +const canImport = computed(() => !!preview.value && !!props.prefillConnectionId && !!props.prefillTable && mappingValidation.value.valid && !running.value); +const canGoBack = computed(() => wizardStep.value !== "source" && wizardStep.value !== "execution" && !running.value); +const canGoNext = computed(() => { + if (wizardStep.value === "source") return !!selectedSource.value && !!sourceFormat.value; + if (wizardStep.value === "options") return !!preview.value; + if (wizardStep.value === "mapping") return mappingValidation.value.valid; + return false; +}); const progressPercent = computed(() => { const p = progress.value; if (!p || p.totalRows <= 0) return 0; @@ -65,9 +106,32 @@ const targetLabel = computed(() => { const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable].filter(Boolean); return pieces.join(" / "); }); +const selectedSourceName = computed(() => { + const source = selectedSource.value; + if (!source) return ""; + return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name; +}); +const parseOptions = computed(() => ({ + delimiter: sourceFormat.value === "tsv" ? "\\t" : sourceFormat.value === "csv" ? "," : delimiter.value, + hasHeader: hasHeader.value, + trimValues: trimValues.value, + emptyStringAsNull: emptyStringAsNull.value, + sheetName: sourceFormat.value === "excel" ? selectedSheet.value || null : null, + jsonShape: sourceFormat.value === "json" ? jsonShape.value : null, +})); +const terminalStatus = computed(() => progress.value?.status && ["done", "error", "cancelled"].includes(progress.value.status)); function resetState() { targetColumns.value = []; + selectedSource.value = null; + sourceFormat.value = "csv"; + delimiter.value = ","; + hasHeader.value = true; + trimValues.value = false; + emptyStringAsNull.value = true; + selectedSheet.value = ""; + jsonShape.value = "auto"; + previewLimit.value = 50; preview.value = null; columnMapping.value = {}; importMode.value = "append"; @@ -77,6 +141,16 @@ function resetState() { importId.value = ""; progress.value = null; errorMessage.value = ""; + wizardStep.value = "source"; +} + +function detectFormat(name: string): api.TableImportSourceFormat { + const lower = name.toLowerCase(); + if (lower.endsWith(".tsv")) return "tsv"; + if (lower.endsWith(".txt")) return "delimited"; + if (lower.endsWith(".json")) return "json"; + if (lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".xlsm")) return "excel"; + return "csv"; } function applyAutoMapping() { @@ -101,18 +175,23 @@ async function loadTargetColumns() { } async function previewSelectedImportFile(fileOrPath: string | File) { - if (isTauriRuntime()) { - return api.previewTableImportFile(fileOrPath as string); - } - const { previewTableImportFile } = await import("@/lib/backend/http"); - return previewTableImportFile(fileOrPath as File); + return api.previewTableImportFile(fileOrPath, { + sourceFormat: sourceFormat.value, + parseOptions: parseOptions.value, + previewLimit: Math.max(1, Number(previewLimit.value) || 50), + }); } -async function loadPreview(fileOrPath: string | File) { +async function loadPreview(fileOrPath = selectedSource.value) { + if (!fileOrPath) return; loadingPreview.value = true; errorMessage.value = ""; try { - preview.value = await previewSelectedImportFile(fileOrPath); + const nextPreview = await previewSelectedImportFile(fileOrPath); + preview.value = nextPreview; + if (sourceFormat.value === "excel" && !selectedSheet.value && nextPreview.sheets?.length) { + selectedSheet.value = nextPreview.sheets[0]; + } applyAutoMapping(); } catch (e: any) { preview.value = null; @@ -123,6 +202,19 @@ async function loadPreview(fileOrPath: string | File) { } } +function assignSelectedSource(source: string | File) { + selectedSource.value = source; + preview.value = null; + columnMapping.value = {}; + progress.value = null; + errorMessage.value = ""; + const name = typeof source === "string" ? source : source.name; + sourceFormat.value = detectFormat(name); + delimiter.value = sourceFormat.value === "tsv" ? "\\t" : ","; + selectedSheet.value = ""; + wizardStep.value = "options"; +} + async function selectFile() { if (!isTauriRuntime()) { fileInput.value?.click(); @@ -132,15 +224,14 @@ async function selectFile() { const selected = await open({ multiple: false, filters: [ - { name: "Data files", extensions: ["csv", "tsv", "json", "xlsx", "xlsm", "xls"] }, - { name: "CSV", extensions: ["csv", "tsv"] }, + { name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls"] }, + { name: "Text", extensions: ["csv", "tsv", "txt"] }, { name: "JSON", extensions: ["json"] }, { name: "Excel", extensions: ["xlsx", "xlsm", "xls"] }, ], }); if (!selected || Array.isArray(selected)) return; - - await loadPreview(selected); + assignSelectedSource(selected); } async function handleFileInputChange(event: Event) { @@ -148,7 +239,7 @@ async function handleFileInputChange(event: Event) { const file = input.files?.[0]; input.value = ""; if (!file || running.value) return; - await loadPreview(file); + assignSelectedSource(file); } function updateMapping(sourceColumn: string, value: any) { @@ -165,12 +256,34 @@ function formatCell(value: unknown) { return String(value); } +function goBack() { + wizardStep.value = previousTableImportWizardStep(wizardStep.value); +} + +function canOpenStep(step: TableImportWizardStep) { + if (running.value || step === "execution") return false; + if (step === "source") return true; + if (step === "options") return !!selectedSource.value; + if (step === "mapping") return !!preview.value; + if (step === "review") return !!preview.value && mappingValidation.value.valid; + return false; +} + +async function goNext() { + if (wizardStep.value === "options" && !preview.value) { + await loadPreview(); + if (!preview.value) return; + } + wizardStep.value = nextTableImportWizardStep(wizardStep.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 = ""; + wizardStep.value = "execution"; importId.value = uuid(); progress.value = { importId: importId.value, @@ -188,6 +301,9 @@ async function startImport() { schema: props.prefillSchema || "", table: props.prefillTable, filePath: currentPreview.filePath, + sourceRef: currentPreview.sourceRef || null, + sourceFormat: sourceFormat.value, + parseOptions: parseOptions.value, mappings: mappedColumns.value, mode: importMode.value, batchSize: Math.max(1, Number(batchSize.value) || 500), @@ -196,11 +312,19 @@ async function startImport() { progress.value = nextProgress; }, ); + progress.value = { importId: summary.importId, status: "done", rowsImported: summary.rowsImported, totalRows: summary.totalRows }; toast(t("tableImport.success", { count: summary.rowsImported }), 2500); store.invalidateMetadataCache(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined, props.prefillTable); - open.value = false; } catch (e: any) { - errorMessage.value = String(e?.message || e); + const message = String(e?.message || e); + errorMessage.value = message; + progress.value = { + importId: importId.value, + status: progress.value?.status === "cancelled" ? "cancelled" : "error", + rowsImported: progress.value?.rowsImported ?? 0, + totalRows: progress.value?.totalRows ?? currentPreview.totalRows, + error: message, + }; } finally { running.value = false; cancelling.value = false; @@ -213,6 +337,14 @@ async function cancelImport() { await api.cancelTableImport(importId.value); } +function schedulePreviewReload() { + if (!preview.value || !selectedSource.value || loadingPreview.value || running.value) return; + if (previewReloadTimer) clearTimeout(previewReloadTimer); + previewReloadTimer = setTimeout(() => { + void loadPreview(); + }, 250); +} + watch( open, (value) => { @@ -223,13 +355,15 @@ watch( }, { immediate: true }, ); + +watch([sourceFormat, delimiter, hasHeader, trimValues, emptyStringAsNull, selectedSheet, jsonShape, previewLimit], schedulePreviewReload);