Files
dbx/apps/desktop/src/components/import/TableImportDialog.vue
T

1635 lines
76 KiB
Vue

<script setup lang="ts">
import { computed, ref, shallowRef, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
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 { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import {
autoMapImportColumns,
buildTableImportParseOptions,
defaultTableImportEmptyStringAsNull,
formatTableImportElapsed,
nextTableImportWizardStep,
previousTableImportWizardStep,
requiredImportTargetColumns,
resolveTableImportElapsed,
suggestImportTargetDataTypes,
tableImportProgressPercent,
validateImportMappings,
type TableImportWizardStep,
} from "@/lib/table/tableImport";
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
import { metadataSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { ColumnInfo } from "@/types/database";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
const store = useConnectionStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
}>();
type ImportTargetMode = "existing" | "create";
type ImportSource = string | File;
interface BatchImportTask {
id: string;
source: ImportSource;
format: api.TableImportSourceFormat;
sheetName: string;
tableName: string;
preview: api.TableImportPreview;
columnMapping: Record<string, string>;
columnDataTypes: Record<string, string>;
status: "pending" | "running" | "done" | "error" | "cancelled";
rowsImported: number;
error?: string;
}
const SKIP_VALUE = "__skip__";
const TARGET_COLUMNS_TIMEOUT_MS = 15000;
const TARGET_COLUMNS_TIMEOUT = Symbol("target-columns-timeout");
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(TARGET_COLUMNS_TIMEOUT), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
const targetColumns = ref<ColumnInfo[]>([]);
const loadedTargetTableName = ref("");
const existingTableNames = ref<string[]>([]);
const selectedExistingTable = ref(props.prefillTable || "");
const targetMode = ref<ImportTargetMode>(props.prefillTable ? "existing" : "create");
const newTableName = ref("");
const selectedSource = ref<string | File | null>(null);
const batchTasks = ref<BatchImportTask[]>([]);
const activeTaskIndex = ref(0);
const sourceFormat = ref<api.TableImportSourceFormat>("csv");
const preview = shallowRef<api.TableImportPreview | null>(null);
const columnMapping = ref<Record<string, string>>({});
const columnDataTypes = ref<Record<string, string>>({});
const dynamicDataTypeOptions = ref<string[]>([]);
const loadingDataTypeOptions = ref(false);
const loadingExistingTables = ref(false);
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 wizardStep = ref<TableImportWizardStep>("source");
const fileInput = ref<HTMLInputElement | null>(null);
const delimiter = ref(",");
const textEncoding = ref<api.TableImportTextEncoding>("auto");
const titleRow = ref(1);
const dataStartRow = ref(2);
const lastDataRow = ref(0);
const trimValues = ref(false);
const emptyStringAsNull = ref(defaultTableImportEmptyStringAsNull(sourceFormat.value));
const selectedSheet = ref("");
const jsonShape = ref<api.TableImportJsonShape>("auto");
const previewLimit = ref(50);
const liveElapsedMs = ref(0);
let previewReloadTimer: ReturnType<typeof setTimeout> | null = null;
let importElapsedTimer: ReturnType<typeof setInterval> | null = null;
let importStartedAt = 0;
let dataTypeOptionsRequestId = 0;
let existingTablesRequestId = 0;
let targetColumnsRequestId = 0;
let previewRequestId = 0;
let batchEncodingRequestId = 0;
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" },
{ value: "sql", icon: FileCode, labelKey: "tableImport.formatSql", descriptionKey: "tableImport.formatSqlDescription" },
];
const encodingOptions: Array<{ value: api.TableImportTextEncoding; labelKey: string }> = [
{ value: "auto", labelKey: "tableImport.encodingAuto" },
{ value: "utf8", labelKey: "tableImport.encodingUtf8" },
{ value: "gbk", labelKey: "tableImport.encodingGbk" },
{ value: "utf16Le", labelKey: "tableImport.encodingUtf16Le" },
{ value: "utf16Be", labelKey: "tableImport.encodingUtf16Be" },
];
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 structureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(selectedConnection.value));
const targetSchema = computed(() => metadataSchemaForConnection(selectedConnection.value, props.prefillDatabase || "", props.prefillSchema));
const dataTypeOptions = computed(() => mergeDataTypeOptions(dynamicDataTypeOptions.value, getDataTypeOptions(structureDatabaseType.value), Object.values(columnDataTypes.value)));
const hasExistingTarget = computed(() => !!props.prefillTable || loadingExistingTables.value || existingTableNames.value.length > 0);
const targetTableName = computed(() => (targetMode.value === "create" ? newTableName.value.trim() : selectedExistingTable.value));
const existingTargetMetadataReady = computed(() => targetMode.value !== "existing" || (!loadingTarget.value && !!selectedExistingTable.value && loadedTargetTableName.value === selectedExistingTable.value));
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) => {
const targetDataType = targetMode.value === "create" ? String(columnDataTypes.value[sourceColumn] ?? "").trim() : undefined;
return {
sourceColumn,
targetColumn: columnMapping.value[sourceColumn] ?? "",
...(targetMode.value === "create" ? { targetDataType } : {}),
};
})
.filter((mapping) => mapping.targetColumn);
});
const mappedCount = computed(() => mappedColumns.value.length);
const mappingValidation = computed(() => validateImportMappings(mappedColumns.value));
const requiredUnmappedColumns = computed(() =>
requiredImportTargetColumns(
targetColumns.value,
mappedColumns.value.map((mapping) => mapping.targetColumn),
),
);
const isBatchImport = computed(() => targetMode.value === "create" && batchTasks.value.length > 1);
const canImport = computed(() => {
if (running.value || !props.prefillConnectionId || !existingTargetMetadataReady.value) return false;
if (!isBatchImport.value) return !!preview.value && !!targetTableName.value && mappingValidation.value.valid;
const tableNames = batchTasks.value.map((task) => task.tableName.trim().toLowerCase());
if (new Set(tableNames).size !== tableNames.length) return false;
return batchTasks.value.every((task) => {
const mappings = task.preview.columns.map((sourceColumn) => ({ sourceColumn, targetColumn: task.columnMapping[sourceColumn] ?? "", targetDataType: task.columnDataTypes[sourceColumn] ?? "" })).filter((mapping) => mapping.targetColumn);
return !!task.tableName.trim() && validateImportMappings(mappings).valid;
});
});
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 && !!targetTableName.value && existingTargetMetadataReady.value;
if (wizardStep.value === "mapping") return existingTargetMetadataReady.value && mappingValidation.value.valid;
return false;
});
const rawProgressPercent = computed(() => tableImportProgressPercent(progress.value));
const progressPercentFloor = ref(0);
const progressPercent = computed(() => Math.max(rawProgressPercent.value, progressPercentFloor.value));
const currentStepIndex = computed(() => wizardSteps.findIndex((step) => step.value === wizardStep.value));
const targetLabel = computed(() => {
const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, targetTableName.value].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 createColumnSummaries = computed(() =>
mappedColumns.value.map((mapping) => ({
sourceColumn: mapping.sourceColumn,
targetColumn: mapping.targetColumn,
targetDataType: mapping.targetDataType || "",
})),
);
const parseOptions = computed<api.TableImportParseOptions>(() => taskParseOptions(sourceFormat.value, selectedSheet.value));
const terminalStatus = computed(() => !!progress.value?.status && ["done", "error", "cancelled"].includes(progress.value.status));
const displayedElapsedMs = computed(() => resolveTableImportElapsed(liveElapsedMs.value, progress.value?.elapsedMs, terminalStatus.value));
const progressLabelKey = computed(() => {
if (terminalStatus.value) return `tableImport.status_${progress.value?.status || "idle"}`;
return `tableImport.phase_${progress.value?.phase || "writing"}`;
});
function previewRowsLabel(currentPreview: api.TableImportPreview | null | undefined): string {
if (!currentPreview) return "-";
if (currentPreview.totalRowsExact !== false) return currentPreview.totalRows.toLocaleString();
return t("tableImport.previewPartial", { rows: currentPreview.rows.length });
}
function stopImportElapsedClock() {
if (importElapsedTimer) {
clearInterval(importElapsedTimer);
importElapsedTimer = null;
}
}
function refreshImportElapsedClock() {
if (importStartedAt > 0) {
liveElapsedMs.value = Math.max(0, Math.round(performance.now() - importStartedAt));
}
}
function startImportElapsedClock() {
stopImportElapsedClock();
importStartedAt = performance.now();
liveElapsedMs.value = 0;
importElapsedTimer = setInterval(refreshImportElapsedClock, 100);
}
function resetState() {
stopImportElapsedClock();
closeDataTypePicker();
importStartedAt = 0;
liveElapsedMs.value = 0;
previewRequestId++;
batchEncodingRequestId++;
existingTablesRequestId++;
targetColumnsRequestId++;
if (previewReloadTimer) {
clearTimeout(previewReloadTimer);
previewReloadTimer = null;
}
targetColumns.value = [];
loadedTargetTableName.value = "";
existingTableNames.value = [];
selectedExistingTable.value = props.prefillTable || "";
targetMode.value = props.prefillTable ? "existing" : "create";
newTableName.value = "";
selectedSource.value = null;
batchTasks.value = [];
activeTaskIndex.value = 0;
sourceFormat.value = "csv";
delimiter.value = ",";
textEncoding.value = "auto";
titleRow.value = 1;
dataStartRow.value = 2;
lastDataRow.value = 0;
trimValues.value = false;
emptyStringAsNull.value = defaultTableImportEmptyStringAsNull(sourceFormat.value);
selectedSheet.value = "";
jsonShape.value = "auto";
previewLimit.value = 50;
preview.value = null;
columnMapping.value = {};
columnDataTypes.value = {};
importMode.value = "append";
batchSize.value = 500;
loadingPreview.value = false;
loadingExistingTables.value = false;
loadingTarget.value = false;
running.value = false;
cancelling.value = false;
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";
if (lower.endsWith(".sql")) return "sql";
return "csv";
}
function isDelimitedFormat(format: api.TableImportSourceFormat) {
return format === "csv" || format === "tsv" || format === "delimited";
}
function encodingLabel(encoding: api.TableImportTextEncoding) {
return t(encodingOptions.find((option) => option.value === encoding)?.labelKey || "tableImport.encodingAuto");
}
function suggestedTableName(name: string) {
const baseName = name.split(/[\\/]/).pop() || name;
const withoutExtension = baseName.replace(/\.[^.]+$/, "").trim();
return withoutExtension.replace(/[\s-]+/g, "_") || "imported_data";
}
function sourceName(source: ImportSource): string {
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
}
function uniqueTableName(baseName: string, usedNames: Set<string>): string {
const normalizedBase = suggestedTableName(baseName);
let candidate = normalizedBase;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase())) candidate = `${normalizedBase}_${suffix++}`;
usedNames.add(candidate.toLowerCase());
return candidate;
}
function taskParseOptions(format: api.TableImportSourceFormat, sheetName = ""): api.TableImportParseOptions {
return buildTableImportParseOptions({
format,
delimiter: delimiter.value,
textEncoding: textEncoding.value,
titleRow: titleRow.value,
dataStartRow: dataStartRow.value,
lastDataRow: lastDataRow.value,
trimValues: trimValues.value,
emptyStringAsNull: emptyStringAsNull.value,
sheetName,
jsonShape: jsonShape.value,
databaseType: structureDatabaseType.value,
});
}
function importParseOptions(format: api.TableImportSourceFormat, currentPreview: api.TableImportPreview, sheetName = ""): api.TableImportParseOptions {
const options = taskParseOptions(format, sheetName);
// SQL 脚本与分隔文本一样依赖文本编码检测,导入时沿用预览阶段确定的编码避免二次检测结果不一致
if ((isDelimitedFormat(format) || format === "sql") && currentPreview.totalRowsExact !== false && options.encoding === "auto" && currentPreview.effectiveEncoding) {
options.encoding = currentPreview.effectiveEncoding;
}
return options;
}
function preparedImportSource(currentPreview: api.TableImportPreview): api.TableImportPreparedSource {
return {
fingerprint: currentPreview.sourceFingerprint,
columns: currentPreview.columns,
rows: currentPreview.rows,
totalRows: currentPreview.totalRows,
totalRowsExact: currentPreview.totalRowsExact !== false,
effectiveEncoding: currentPreview.effectiveEncoding ?? null,
};
}
function mergeDataTypeOptions(...groups: readonly string[][]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const group of groups) {
for (const option of group) {
const trimmed = option.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
}
return result;
}
function applyAutoMapping() {
const currentPreview = preview.value;
if (!currentPreview) return;
if (targetMode.value === "create") {
columnMapping.value = Object.fromEntries(currentPreview.columns.map((source) => [source, source]));
return;
}
columnMapping.value = autoMapImportColumns(currentPreview.columns, targetColumnNames.value);
}
function applySuggestedColumnDataTypes(currentPreview = preview.value) {
if (targetMode.value !== "create" || !currentPreview) {
columnDataTypes.value = {};
return;
}
const suggested = suggestImportTargetDataTypes(currentPreview.columns, currentPreview.rows, structureDatabaseType.value);
const previous = columnDataTypes.value;
columnDataTypes.value = Object.fromEntries(currentPreview.columns.map((sourceColumn) => [sourceColumn, previous[sourceColumn]?.trim() ? previous[sourceColumn] : suggested[sourceColumn] || "TEXT"]));
}
// 200+ 列时 SearchableSelect 太重,换成一个全局 popover,
// 只有点击输入框时才挂载,避免创建上百分组件实例。
const activeDataTypeColumn = ref<string | null>(null);
const dataTypePickerOpen = ref(false);
const dataTypePickerStyle = ref<Record<string, string>>({});
const activeDataTypeOptionIndex = ref(-1);
const dataTypePickerOptions = computed(() => {
const options = dataTypeOptions.value;
const sourceColumn = activeDataTypeColumn.value;
const currentValue = sourceColumn ? columnDataTypes.value[sourceColumn] : "";
return currentValue && !options.includes(currentValue) ? [...options, currentValue] : options;
});
function closeDataTypePicker() {
dataTypePickerOpen.value = false;
activeDataTypeColumn.value = null;
activeDataTypeOptionIndex.value = -1;
}
function openDataTypePicker(sourceColumn: string, input: HTMLInputElement) {
const rect = input.getBoundingClientRect();
dataTypePickerStyle.value = {
position: "fixed",
top: `${rect.bottom + 2}px`,
left: `${rect.left}px`,
width: `${Math.max(rect.width, 200)}px`,
};
activeDataTypeColumn.value = sourceColumn;
activeDataTypeOptionIndex.value = -1;
dataTypePickerOpen.value = true;
}
function selectDataTypeOption(value: string) {
const sourceColumn = activeDataTypeColumn.value;
if (sourceColumn) updateColumnDataType(sourceColumn, value);
closeDataTypePicker();
}
function handleDataTypePickerKeydown(event: KeyboardEvent, sourceColumn: string, input: HTMLInputElement) {
if (!dataTypePickerOpen.value && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
openDataTypePicker(sourceColumn, input);
}
if (!dataTypePickerOpen.value || !activeDataTypeColumn.value) return;
const options = dataTypePickerOptions.value;
if (event.key === "Escape") {
event.preventDefault();
closeDataTypePicker();
} else if (event.key === "ArrowDown" && options.length) {
event.preventDefault();
activeDataTypeOptionIndex.value = (activeDataTypeOptionIndex.value + 1) % options.length;
} else if (event.key === "ArrowUp" && options.length) {
event.preventDefault();
activeDataTypeOptionIndex.value = activeDataTypeOptionIndex.value <= 0 ? options.length - 1 : activeDataTypeOptionIndex.value - 1;
} else if (event.key === "Enter" && activeDataTypeOptionIndex.value >= 0) {
event.preventDefault();
selectDataTypeOption(options[activeDataTypeOptionIndex.value]);
}
}
async function loadDataTypeOptions() {
const requestId = ++dataTypeOptionsRequestId;
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase || "";
if (!connectionId || !database || targetMode.value !== "create") {
dynamicDataTypeOptions.value = [];
loadingDataTypeOptions.value = false;
return;
}
loadingDataTypeOptions.value = true;
try {
await store.ensureConnected(connectionId);
const options = await api.listDataTypes(connectionId, database);
if (requestId !== dataTypeOptionsRequestId) return;
dynamicDataTypeOptions.value = mergeDataTypeOptions(options);
} catch {
if (requestId === dataTypeOptionsRequestId) {
dynamicDataTypeOptions.value = [];
}
} finally {
if (requestId === dataTypeOptionsRequestId) {
loadingDataTypeOptions.value = false;
}
}
}
async function loadExistingTables() {
if (props.prefillTable || loadingExistingTables.value || existingTableNames.value.length || !props.prefillConnectionId || !props.prefillDatabase) return;
const requestId = ++existingTablesRequestId;
loadingExistingTables.value = true;
errorMessage.value = "";
try {
await store.ensureConnected(props.prefillConnectionId);
const tables = await api.listTables(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, undefined, undefined, undefined, ["TABLE"]);
if (requestId !== existingTablesRequestId) return;
existingTableNames.value = tables.map((table) => table.name);
} catch (e: any) {
if (requestId === existingTablesRequestId) {
existingTableNames.value = [];
errorMessage.value = String(e?.message || e);
}
} finally {
if (requestId === existingTablesRequestId) loadingExistingTables.value = false;
}
}
async function loadTargetColumns() {
const tableName = selectedExistingTable.value;
if (targetMode.value !== "existing" || !props.prefillConnectionId || !props.prefillDatabase || !tableName) {
targetColumnsRequestId++;
targetColumns.value = [];
loadedTargetTableName.value = "";
columnMapping.value = {};
loadingTarget.value = false;
return;
}
const requestId = ++targetColumnsRequestId;
targetColumns.value = [];
loadedTargetTableName.value = "";
columnMapping.value = {};
loadingTarget.value = true;
errorMessage.value = "";
try {
await store.ensureConnected(props.prefillConnectionId);
const columns = await withTimeout(api.getColumns(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, tableName), TARGET_COLUMNS_TIMEOUT_MS);
if (requestId !== targetColumnsRequestId) return;
targetColumns.value = columns;
loadedTargetTableName.value = tableName;
applyAutoMapping();
} catch (e: any) {
if (requestId === targetColumnsRequestId) {
targetColumns.value = [];
loadedTargetTableName.value = "";
columnMapping.value = {};
errorMessage.value = e === TARGET_COLUMNS_TIMEOUT ? t("tableImport.targetColumnsTimeout") : String(e?.message || e);
}
} finally {
if (requestId === targetColumnsRequestId) loadingTarget.value = false;
}
}
async function previewSelectedImportFile(fileOrPath: string | File) {
const reusablePreview = preview.value?.sourceRef ? preview.value : null;
return api.previewTableImportFile(reusablePreview?.filePath || fileOrPath, {
sourceRef: reusablePreview?.sourceRef || null,
sourceFormat: sourceFormat.value,
parseOptions: parseOptions.value,
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
});
}
async function loadPreview(fileOrPath = selectedSource.value) {
if (!fileOrPath) return;
const requestId = ++previewRequestId;
loadingPreview.value = true;
errorMessage.value = "";
try {
const nextPreview = await previewSelectedImportFile(fileOrPath);
if (requestId !== previewRequestId) return;
preview.value = nextPreview;
if (sourceFormat.value === "excel" && !selectedSheet.value && nextPreview.sheets?.length) {
selectedSheet.value = nextPreview.sheets[0];
}
applyAutoMapping();
applySuggestedColumnDataTypes(nextPreview);
} catch (e: any) {
if (requestId !== previewRequestId) return;
preview.value = null;
columnMapping.value = {};
columnDataTypes.value = {};
errorMessage.value = String(e?.message || e);
} finally {
if (requestId === previewRequestId) loadingPreview.value = false;
}
}
function assignSelectedSource(source: string | File) {
batchTasks.value = [];
selectedSource.value = source;
preview.value = null;
columnMapping.value = {};
columnDataTypes.value = {};
progress.value = null;
errorMessage.value = "";
const name = typeof source === "string" ? source : source.name;
sourceFormat.value = detectFormat(name);
emptyStringAsNull.value = defaultTableImportEmptyStringAsNull(sourceFormat.value);
if (!newTableName.value.trim()) {
newTableName.value = suggestedTableName(name);
}
delimiter.value = sourceFormat.value === "tsv" ? "\\t" : ",";
selectedSheet.value = "";
wizardStep.value = "options";
}
function activateBatchTask(index: number) {
const task = batchTasks.value[index];
if (!task || running.value) return;
activeTaskIndex.value = index;
selectedSource.value = task.source;
sourceFormat.value = task.format;
selectedSheet.value = task.sheetName;
newTableName.value = task.tableName;
preview.value = task.preview;
columnMapping.value = { ...task.columnMapping };
columnDataTypes.value = { ...task.columnDataTypes };
}
function saveActiveBatchTask() {
if (!isBatchImport.value) return;
const task = batchTasks.value[activeTaskIndex.value];
if (!task) return;
task.tableName = newTableName.value.trim();
task.columnMapping = { ...columnMapping.value };
task.columnDataTypes = { ...columnDataTypes.value };
}
async function prepareBatchSources(sources: ImportSource[]) {
loadingPreview.value = true;
errorMessage.value = "";
const tasks: BatchImportTask[] = [];
const usedNames = new Set<string>();
const formats = sources.map((source) => detectFormat(sourceName(source)));
emptyStringAsNull.value = formats.length && formats.every((format) => format === formats[0]) ? defaultTableImportEmptyStringAsNull(formats[0]!) : true;
try {
for (const [index, source] of sources.entries()) {
const format = formats[index]!;
const initialPreview = await api.previewTableImportFile(source, {
sourceFormat: format,
parseOptions: taskParseOptions(format),
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
});
const sheets = format === "excel" && initialPreview.sheets?.length ? initialPreview.sheets : [""];
for (const sheetName of sheets) {
const reusableSource = initialPreview.sourceRef ? initialPreview.filePath : source;
const effectiveSheetName = sheetName && sheetName === initialPreview.sheets?.[0] ? "" : sheetName;
const taskPreview = effectiveSheetName
? await api.previewTableImportFile(reusableSource, {
sourceRef: initialPreview.sourceRef || null,
sourceFormat: format,
parseOptions: taskParseOptions(format, effectiveSheetName),
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
})
: initialPreview;
const tableBase = sheetName ? `${suggestedTableName(sourceName(source))}_${sheetName}` : sourceName(source);
tasks.push({
id: uuid(),
source,
format,
sheetName: effectiveSheetName,
tableName: uniqueTableName(tableBase, usedNames),
preview: taskPreview,
columnMapping: Object.fromEntries(taskPreview.columns.map((column) => [column, column])),
columnDataTypes: suggestImportTargetDataTypes(taskPreview.columns, taskPreview.rows, structureDatabaseType.value),
status: "pending",
rowsImported: 0,
});
}
}
batchTasks.value = tasks;
if (tasks.length) {
activateBatchTask(0);
wizardStep.value = "options";
}
} catch (e: any) {
batchTasks.value = [];
errorMessage.value = String(e?.message || e);
} finally {
loadingPreview.value = false;
}
}
async function selectFile() {
if (!isTauriRuntime()) {
fileInput.value?.click();
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: targetMode.value === "create",
filters: [
{ name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls", "sql"] },
{ name: "Text", extensions: ["csv", "tsv", "txt"] },
{ name: "JSON", extensions: ["json"] },
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
{ name: "SQL", extensions: ["sql"] },
],
});
if (!selected) return;
const sources = Array.isArray(selected) ? selected : [selected];
if (targetMode.value === "create") await prepareBatchSources(sources);
else assignSelectedSource(sources[0]);
}
async function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = "";
if (!files.length || running.value) return;
if (targetMode.value === "create") await prepareBatchSources(files);
else assignSelectedSource(files[0]);
}
function updateMapping(sourceColumn: string, value: any) {
const target = String(value);
columnMapping.value = {
...columnMapping.value,
[sourceColumn]: target === SKIP_VALUE ? "" : target,
};
}
function updateColumnDataType(sourceColumn: string, value: any) {
columnDataTypes.value = {
...columnDataTypes.value,
[sourceColumn]: String(value),
};
}
function formatCell(value: unknown) {
if (value === null) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
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 && existingTargetMetadataReady.value;
if (step === "review") return !!preview.value && existingTargetMetadataReady.value && mappingValidation.value.valid;
return false;
}
function wizardStepIndex(step: TableImportWizardStep) {
return wizardSteps.findIndex((item) => item.value === step);
}
function isWizardStepActive(step: TableImportWizardStep) {
return step === wizardStep.value;
}
function isWizardStepComplete(step: TableImportWizardStep) {
return wizardStepIndex(step) < currentStepIndex.value;
}
function wizardStepConnectorClass(index: number, leading: boolean) {
return (leading ? index <= currentStepIndex.value : index < currentStepIndex.value) ? "bg-primary/60" : "bg-border";
}
function wizardStepTextClass(step: TableImportWizardStep) {
if (isWizardStepActive(step)) return "text-foreground";
if (isWizardStepComplete(step)) return "text-foreground hover:bg-muted/40";
return "text-muted-foreground";
}
function wizardStepCircleClass(step: TableImportWizardStep) {
if (isWizardStepActive(step)) return "border-primary bg-primary text-primary-foreground shadow-sm";
if (isWizardStepComplete(step)) return "border-primary/70 bg-background text-primary";
return "border-border bg-background text-muted-foreground";
}
async function goNext() {
if (wizardStep.value === "options" && !preview.value) {
await loadPreview();
if (!preview.value) return;
}
wizardStep.value = nextTableImportWizardStep(wizardStep.value);
}
async function startImport() {
saveActiveBatchTask();
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) {
return;
}
if (isBatchImport.value) {
await startBatchImport();
return;
}
const currentPreview = preview.value;
const tableName = targetTableName.value;
if (!canImport.value || !currentPreview || !props.prefillConnectionId || !tableName) return;
running.value = true;
progressPercentFloor.value = 0;
cancelling.value = false;
errorMessage.value = "";
wizardStep.value = "execution";
importId.value = uuid();
startImportElapsedClock();
progress.value = {
importId: importId.value,
status: "running",
phase: "preparing",
rowsImported: 0,
totalRows: currentPreview.totalRows,
totalRowsExact: currentPreview.totalRowsExact !== false,
bytesRead: 0,
totalBytes: currentPreview.sizeBytes,
elapsedMs: 0,
};
try {
const summary = await api.importTableFile(
{
importId: importId.value,
connectionId: props.prefillConnectionId,
database: props.prefillDatabase || "",
schema: targetSchema.value,
table: tableName,
filePath: currentPreview.filePath,
sourceRef: currentPreview.sourceRef || null,
sourceFormat: sourceFormat.value,
// Execution must parse the same worksheet that produced the preview and mappings.
parseOptions: importParseOptions(sourceFormat.value, currentPreview, selectedSheet.value),
mappings: mappedColumns.value,
mode: targetMode.value === "create" ? "append" : importMode.value,
createTable: targetMode.value === "create",
batchSize: Math.max(1, Number(batchSize.value) || 500),
dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
preparedSource: preparedImportSource(currentPreview),
},
(nextProgress) => {
progress.value = { ...nextProgress, elapsedMs: nextProgress.elapsedMs ?? liveElapsedMs.value };
},
);
progress.value = { importId: summary.importId, status: "done", rowsImported: summary.rowsImported, totalRows: summary.totalRows, elapsedMs: summary.elapsedMs };
toast(t("tableImport.success", { count: summary.rowsImported }), 2500);
store.invalidateMetadataCache(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined, tableName);
if (targetMode.value === "create") {
store.refreshObjectListTreeNode(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined).catch((error) => {
console.warn("[DBX][table-import:refresh-created-table-failed]", error);
});
}
} catch (e: any) {
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,
elapsedMs: progress.value?.elapsedMs ?? liveElapsedMs.value,
error: message,
};
} finally {
refreshImportElapsedClock();
stopImportElapsedClock();
running.value = false;
cancelling.value = false;
}
}
async function startBatchImport() {
if (!props.prefillConnectionId || !batchTasks.value.length || running.value) return;
running.value = true;
progressPercentFloor.value = 0;
cancelling.value = false;
errorMessage.value = "";
wizardStep.value = "execution";
const totalRowsExact = batchTasks.value.every((task) => task.preview.totalRowsExact !== false);
const totalRows = totalRowsExact ? batchTasks.value.reduce((sum, task) => sum + task.preview.totalRows, 0) : 0;
const totalBytes = batchTasks.value.reduce((sum, task) => sum + task.preview.sizeBytes, 0);
let completedRows = 0;
let completedBytes = 0;
importId.value = uuid();
startImportElapsedClock();
progress.value = { importId: importId.value, status: "running", phase: "preparing", rowsImported: 0, totalRows, totalRowsExact, bytesRead: 0, totalBytes, elapsedMs: 0 };
try {
const tableNames = batchTasks.value.map((task) => task.tableName.trim().toLowerCase());
if (new Set(tableNames).size !== tableNames.length) throw new Error("Target table names must be unique");
for (let index = 0; index < batchTasks.value.length; index++) {
const task = batchTasks.value[index];
activeTaskIndex.value = index;
task.status = "running";
importId.value = uuid();
const mappings = task.preview.columns
.map((sourceColumn) => ({
sourceColumn,
targetColumn: task.columnMapping[sourceColumn] ?? "",
targetDataType: String(task.columnDataTypes[sourceColumn] ?? "").trim(),
}))
.filter((mapping) => mapping.targetColumn);
const validation = validateImportMappings(mappings);
if (!task.tableName.trim() || !validation.valid) {
throw new Error(validation.errors[0] || "Target table name is required");
}
const summary = await api.importTableFile(
{
importId: importId.value,
connectionId: props.prefillConnectionId,
database: props.prefillDatabase || "",
schema: targetSchema.value,
table: task.tableName,
filePath: task.preview.filePath,
sourceRef: task.preview.sourceRef || null,
sourceFormat: task.format,
parseOptions: importParseOptions(task.format, task.preview, task.sheetName),
mappings,
mode: "append",
createTable: true,
batchSize: Math.max(1, Number(batchSize.value) || 500),
dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
preparedSource: preparedImportSource(task.preview),
retainSource: true,
},
(nextProgress) => {
task.rowsImported = nextProgress.rowsImported;
const hasRemainingTasks = index < batchTasks.value.length - 1;
const aggregateStatus = nextProgress.status === "done" && hasRemainingTasks ? "running" : nextProgress.status;
const aggregatePhase = nextProgress.status === "done" && hasRemainingTasks ? "preparing" : nextProgress.phase;
progress.value = {
...nextProgress,
status: aggregateStatus,
phase: aggregatePhase,
rowsImported: completedRows + nextProgress.rowsImported,
totalRows,
totalRowsExact,
bytesRead: completedBytes + (nextProgress.bytesRead ?? 0),
totalBytes,
elapsedMs: liveElapsedMs.value,
};
},
);
task.status = "done";
task.rowsImported = summary.rowsImported;
completedRows += summary.rowsImported;
completedBytes += task.preview.sizeBytes;
store.invalidateMetadataCache(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined, task.tableName);
}
refreshImportElapsedClock();
progress.value = { importId: importId.value, status: "done", phase: "done", rowsImported: completedRows, totalRows: completedRows, totalRowsExact: true, bytesRead: totalBytes, totalBytes, elapsedMs: liveElapsedMs.value };
toast(t("tableImport.success", { count: completedRows }), 2500);
store.refreshObjectListTreeNode(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined).catch((error) => {
console.warn("[DBX][table-import:refresh-created-table-failed]", error);
});
} catch (e: any) {
const task = batchTasks.value[activeTaskIndex.value];
const message = String(e?.message || e);
if (task) {
task.status = progress.value?.status === "cancelled" ? "cancelled" : "error";
task.error = message;
}
errorMessage.value = message;
progress.value = {
importId: importId.value,
status: progress.value?.status === "cancelled" ? "cancelled" : "error",
rowsImported: progress.value?.rowsImported ?? completedRows,
totalRows,
elapsedMs: progress.value?.elapsedMs ?? liveElapsedMs.value,
error: message,
};
} finally {
refreshImportElapsedClock();
stopImportElapsedClock();
await releaseTableImportSources();
running.value = false;
cancelling.value = false;
}
}
async function releaseTableImportSources() {
const sourceRefs = new Set<string>();
if (preview.value?.sourceRef) sourceRefs.add(preview.value.sourceRef);
for (const task of batchTasks.value) {
if (task.preview.sourceRef) sourceRefs.add(task.preview.sourceRef);
}
await Promise.allSettled([...sourceRefs].map((sourceRef) => api.releaseTableImportSource(sourceRef)));
}
async function cancelImport() {
if (!importId.value) return;
cancelling.value = true;
await api.cancelTableImport(importId.value);
}
function schedulePreviewReload() {
// Batch tasks own independent previews and mappings; reloading the active task would overwrite its saved configuration.
if (isBatchImport.value || !preview.value || !selectedSource.value || loadingPreview.value || running.value) return;
if (previewReloadTimer) clearTimeout(previewReloadTimer);
previewReloadTimer = setTimeout(() => {
void loadPreview();
}, 250);
}
function schedulePreviewReloadAfterEncodingChange() {
if (isBatchImport.value) {
void reloadBatchPreviewsForEncoding();
return;
}
if (!selectedSource.value || running.value) return;
previewRequestId++;
if (previewReloadTimer) clearTimeout(previewReloadTimer);
previewReloadTimer = setTimeout(() => {
void loadPreview();
}, 250);
}
async function reloadBatchPreviewsForEncoding() {
if (!isBatchImport.value || running.value) return;
const requestId = ++batchEncodingRequestId;
loadingPreview.value = true;
errorMessage.value = "";
try {
for (const task of batchTasks.value) {
// SQL 脚本同样是文本源,编码变化时需要重新预览
if (!isDelimitedFormat(task.format) && task.format !== "sql") continue;
const reusableSource = task.preview.sourceRef ? task.preview.filePath : task.source;
const nextPreview = await api.previewTableImportFile(reusableSource, {
sourceRef: task.preview.sourceRef || null,
sourceFormat: task.format,
parseOptions: taskParseOptions(task.format, task.sheetName),
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
});
if (requestId !== batchEncodingRequestId) return;
task.preview = nextPreview;
task.columnMapping = Object.fromEntries(nextPreview.columns.map((column) => [column, column]));
task.columnDataTypes = suggestImportTargetDataTypes(nextPreview.columns, nextPreview.rows, structureDatabaseType.value);
}
activateBatchTask(activeTaskIndex.value);
} catch (e: any) {
if (requestId === batchEncodingRequestId) {
preview.value = null;
errorMessage.value = String(e?.message || e);
}
} finally {
if (requestId === batchEncodingRequestId) loadingPreview.value = false;
}
}
watch(
open,
(value) => {
if (value) {
resetState();
void loadTargetColumns();
void loadDataTypeOptions();
} else if (!running.value) {
void releaseTableImportSources();
}
},
{ immediate: true },
);
watch([sourceFormat, delimiter, titleRow, dataStartRow, lastDataRow, trimValues, emptyStringAsNull, selectedSheet, jsonShape, previewLimit], schedulePreviewReload);
watch(textEncoding, schedulePreviewReloadAfterEncodingChange);
watch([newTableName, columnMapping, columnDataTypes], saveActiveBatchTask, { deep: true });
watch(wizardStep, (step) => {
if (step !== "mapping") closeDataTypePicker();
if (step === "options") void loadExistingTables();
});
watch(targetMode, (mode) => {
if (mode === "existing") {
columnDataTypes.value = {};
dynamicDataTypeOptions.value = [];
void loadTargetColumns();
} else {
targetColumnsRequestId++;
targetColumns.value = [];
loadedTargetTableName.value = "";
loadingTarget.value = false;
importMode.value = "append";
applyAutoMapping();
applySuggestedColumnDataTypes();
void loadDataTypeOptions();
}
});
watch(selectedExistingTable, () => {
if (targetMode.value === "existing") void loadTargetColumns();
});
watch(rawProgressPercent, (percent) => {
if (progress.value?.status === "running") {
progressPercentFloor.value = Math.max(progressPercentFloor.value, percent);
} else if (progress.value?.status === "done") {
progressPercentFloor.value = 100;
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogScrollContent class="flex max-h-[calc(var(--dbx-viewport-height)-6rem)] min-h-0 flex-col overflow-hidden sm:max-w-[980px]" :trap-focus="false" @interact-outside.prevent>
<DialogHeader class="shrink-0 pr-8">
<DialogTitle class="flex items-center gap-2 text-base">
<FileUp class="h-4 w-4" />
{{ t("tableImport.title") }}
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto py-2 pr-1">
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<input ref="fileInput" type="file" accept=".csv,.tsv,.txt,.json,.xlsx,.xlsm,.xls,.sql" :multiple="targetMode === 'create'" class="hidden" @change="handleFileInputChange" />
<div class="flex h-10 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-3">
<span class="shrink-0 text-xs text-muted-foreground">{{ t("tableImport.target") }}</span>
<span class="min-w-0 truncate text-sm font-medium">
{{ targetLabel || t("editor.noDatabase") }}
</span>
</div>
<Button variant="outline" class="h-10 px-3" :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" />
{{ selectedSource ? t("tableImport.changeFile") : t("tableImport.selectFile") }}
</Button>
</div>
<div v-if="isBatchImport" class="rounded-md border">
<div class="flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
<span>{{ batchTasks.length }} {{ t("tableImport.file") }}</span>
<span class="text-muted-foreground">{{ selectedSourceName }}</span>
</div>
<div class="flex max-h-32 flex-wrap gap-1.5 overflow-auto p-2">
<button
v-for="(task, index) in batchTasks"
:key="task.id"
type="button"
class="flex max-w-[260px] items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors"
:class="index === activeTaskIndex ? 'border-primary bg-primary/10 text-primary' : 'hover:bg-muted/60'"
:disabled="running"
@click="
saveActiveBatchTask();
activateBatchTask(index);
"
>
<CheckCircle2 v-if="task.status === 'done'" class="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<Loader2 v-else-if="task.status === 'running'" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="task.status === 'error'" class="h-3.5 w-3.5 shrink-0 text-destructive" />
<FileSpreadsheet v-else-if="task.format === 'excel'" class="h-3.5 w-3.5 shrink-0" />
<FileText v-else class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ task.tableName }}</span>
</button>
</div>
</div>
<nav class="rounded-md border bg-muted/20 px-3 py-2" :aria-label="t('tableImport.progress')">
<ol class="grid grid-cols-5">
<li v-for="(step, index) in wizardSteps" :key="step.value" class="relative flex min-w-0 justify-center">
<div v-if="index > 0" class="pointer-events-none absolute left-0 right-1/2 top-3.5 h-px" :class="wizardStepConnectorClass(index, true)" />
<div v-if="index < wizardSteps.length - 1" class="pointer-events-none absolute left-1/2 right-0 top-3.5 h-px" :class="wizardStepConnectorClass(index, false)" />
<button
type="button"
class="relative z-10 flex min-w-0 flex-col items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
:class="[wizardStepTextClass(step.value), canOpenStep(step.value) ? 'cursor-pointer' : 'cursor-default']"
:disabled="!canOpenStep(step.value)"
:aria-current="isWizardStepActive(step.value) ? 'step' : undefined"
@click="wizardStep = step.value"
>
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold" :class="wizardStepCircleClass(step.value)">
<Check v-if="isWizardStepComplete(step.value)" class="h-3.5 w-3.5" />
<span v-else>{{ index + 1 }}</span>
</span>
<span class="max-w-full truncate">{{ t(step.labelKey) }}</span>
</button>
</li>
</ol>
</nav>
<div v-if="wizardStep === 'source'" class="space-y-4">
<div class="rounded-md border border-dashed p-6 text-center">
<FileUp class="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
<div class="text-sm font-medium">{{ selectedSourceName || t("tableImport.noFileSelected") }}</div>
<Button class="mt-4" size="sm" @click="selectFile">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.selectFile") }}
</Button>
</div>
<div class="grid grid-cols-6 gap-2">
<button v-for="format in formatOptions" :key="format.value" type="button" class="min-h-20 rounded-md border px-3 py-2 text-left" :class="sourceFormat === format.value ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" @click="sourceFormat = format.value">
<component :is="format.icon" class="mb-2 h-4 w-4 text-muted-foreground" />
<div class="text-xs font-medium">{{ t(format.labelKey) }}</div>
<div class="mt-1 text-[11px] leading-snug text-muted-foreground">{{ t(format.descriptionKey) }}</div>
</button>
</div>
</div>
<div v-else-if="wizardStep === 'options'" class="space-y-4">
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sourceFormat") }}</Label>
<Select :model-value="sourceFormat" @update:model-value="(value: any) => (sourceFormat = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="format in formatOptions" :key="format.value" :value="format.value">
{{ t(format.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.previewRows") }}</Label>
<Input v-model.number="previewLimit" type="number" min="1" max="500" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sourceFile") }}</Label>
<div class="flex h-8 items-center rounded-md border px-2 text-xs">
<span class="truncate">{{ selectedSourceName || t("tableImport.noFileSelected") }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(220px,320px)] gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.targetMode") }}</Label>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="rounded-md border px-3 py-2 text-left text-xs" :class="targetMode === 'existing' ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" :disabled="!hasExistingTarget" @click="targetMode = 'existing'">
<div class="font-medium">{{ t("tableImport.existingTable") }}</div>
<div class="mt-1 truncate text-[11px] text-muted-foreground">{{ selectedExistingTable || t("tableImport.noExistingTarget") }}</div>
</button>
<button type="button" class="rounded-md border px-3 py-2 text-left text-xs" :class="targetMode === 'create' ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" @click="targetMode = 'create'">
<div class="font-medium">{{ t("tableImport.createTable") }}</div>
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("tableImport.createTableHint") }}</div>
</button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.targetTableName") }}</Label>
<Input v-if="targetMode === 'create'" v-model="newTableName" class="h-8 text-xs font-mono" />
<div v-else-if="props.prefillTable" class="flex h-8 items-center rounded-md border px-2 text-xs">
<span class="truncate">{{ props.prefillTable }}</span>
</div>
<SearchableSelect
v-else
:model-value="selectedExistingTable"
:options="existingTableNames"
:placeholder="t('tableImport.noExistingTarget')"
:search-placeholder="t('tableImport.searchExistingTables')"
:empty-text="t('tableImport.noTables')"
:loading-text="t('tableImport.loadingTables')"
:loading="loadingExistingTables"
:disabled="loadingExistingTables || !existingTableNames.length"
trigger-class="h-8 font-mono text-xs"
@update:model-value="(value) => (selectedExistingTable = value)"
/>
</div>
</div>
<div v-if="sourceFormat === 'csv' || sourceFormat === 'tsv' || sourceFormat === 'delimited'" class="grid grid-cols-5 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.encoding") }}</Label>
<Select :model-value="textEncoding" @update:model-value="(value: any) => (textEncoding = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in encodingOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="textEncoding === 'auto' && preview?.effectiveEncoding" class="truncate text-[11px] text-muted-foreground" :title="t('tableImport.encodingDetected', { encoding: encodingLabel(preview.effectiveEncoding) })">
{{ t("tableImport.encodingDetected", { encoding: encodingLabel(preview.effectiveEncoding) }) }}
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.delimiter") }}</Label>
<Input v-model="delimiter" :disabled="sourceFormat !== 'delimited'" class="h-8 text-xs font-mono" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.titleRow") }}</Label>
<Input v-model.number="titleRow" type="number" min="0" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.dataStartRow") }}</Label>
<Input v-model.number="dataStartRow" type="number" min="1" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.lastDataRow") }}</Label>
<Input v-model.number="lastDataRow" type="number" min="0" class="h-8 text-xs" />
</div>
<label class="flex items-center gap-2 text-xs">
<input v-model="trimValues" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.trimValues") }}
</label>
<label class="flex items-center gap-2 text-xs">
<input v-model="emptyStringAsNull" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.emptyStringAsNull") }}
</label>
</div>
<div v-else-if="sourceFormat === 'sql'" class="grid grid-cols-5 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.encoding") }}</Label>
<Select :model-value="textEncoding" @update:model-value="(value: any) => (textEncoding = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in encodingOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="textEncoding === 'auto' && preview?.effectiveEncoding" class="truncate text-[11px] text-muted-foreground" :title="t('tableImport.encodingDetected', { encoding: encodingLabel(preview.effectiveEncoding) })">
{{ t("tableImport.encodingDetected", { encoding: encodingLabel(preview.effectiveEncoding) }) }}
</div>
</div>
</div>
<div v-else-if="sourceFormat === 'json'" class="grid grid-cols-2 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.jsonShape") }}</Label>
<Select :model-value="jsonShape" @update:model-value="(value: any) => (jsonShape = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("tableImport.jsonShapeAuto") }}</SelectItem>
<SelectItem value="objects">{{ t("tableImport.jsonShapeObjects") }}</SelectItem>
<SelectItem value="arrays">{{ t("tableImport.jsonShapeArrays") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-else-if="sourceFormat === 'excel'" class="grid grid-cols-4 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sheet") }}</Label>
<Select :model-value="selectedSheet" :disabled="!preview?.sheets?.length" @update:model-value="(value: any) => (selectedSheet = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('tableImport.firstSheet')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="sheet in preview?.sheets || []" :key="sheet" :value="sheet">{{ sheet }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.titleRow") }}</Label>
<Input v-model.number="titleRow" type="number" min="0" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.dataStartRow") }}</Label>
<Input v-model.number="dataStartRow" type="number" min="1" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.lastDataRow") }}</Label>
<Input v-model.number="lastDataRow" type="number" min="0" class="h-8 text-xs" />
</div>
</div>
<div class="flex items-center gap-2">
<Button size="sm" :disabled="!selectedSource || loadingPreview" @click="loadPreview()">
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-1.5 h-3.5 w-3.5" />
{{ preview ? t("tableImport.reloadPreview") : t("tableImport.loadPreview") }}
</Button>
<span v-if="preview" class="text-xs text-muted-foreground">
{{ preview.totalRowsExact !== false ? t("tableImport.previewReady", { rows: preview.totalRows, columns: preview.columns.length }) : t("tableImport.previewReadyPartial", { rows: preview.rows.length, columns: preview.columns.length }) }}
</span>
</div>
</div>
<div v-else-if="wizardStep === 'mapping'" class="space-y-3">
<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">{{ previewRowsLabel(preview) }}</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 gap-3" :class="targetMode === 'create' ? 'grid-cols-[minmax(360px,460px)_1fr]' : 'grid-cols-[minmax(240px,300px)_1fr]'">
<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-[320px] overflow-auto p-2">
<div class="grid items-center gap-2 border-b px-1 pb-1 text-[11px] font-medium text-muted-foreground" :class="targetMode === 'create' ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(92px,120px)]' : 'grid-cols-[1fr_1fr]'">
<span>{{ t("tableImport.sourceColumn") }}</span>
<span>{{ t("tableImport.targetColumn") }}</span>
<span v-if="targetMode === 'create'">{{ t("tableImport.targetDataType") }}</span>
</div>
<div v-for="sourceColumn in preview.columns" :key="sourceColumn" class="grid items-center gap-2 py-1" :class="targetMode === 'create' ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(92px,120px)]' : 'grid-cols-[1fr_1fr]'">
<div class="truncate font-mono text-xs" :title="sourceColumn">
{{ sourceColumn }}
</div>
<!-- 原生 input/select: 200+ 列时 Vue Input/Select 组件是渲染瓶颈 -->
<input
v-if="targetMode === 'create'"
:value="columnMapping[sourceColumn] ?? sourceColumn"
class="h-7 w-full min-w-0 rounded-md border bg-background px-2 text-xs font-mono shadow-none hover:bg-muted/30 focus-visible:ring-1 focus-visible:ring-ring/25"
@input="(e) => updateMapping(sourceColumn, (e.target as HTMLInputElement).value)"
/>
<select
v-else
:value="columnMapping[sourceColumn] || SKIP_VALUE"
class="h-7 w-full min-w-0 rounded-md border bg-background px-2 text-xs font-mono shadow-none hover:bg-muted/30 focus-visible:ring-1 focus-visible:ring-ring/25"
@change="(e) => updateMapping(sourceColumn, (e.target as HTMLSelectElement).value)"
>
<option :value="SKIP_VALUE">{{ t("tableImport.skipColumn") }}</option>
<option v-for="column in targetColumns" :key="column.name" :value="column.name">
{{ column.name }}
</option>
</select>
<!-- 数据类型:plain input + 全局 popover,点击才弹出 -->
<div v-if="targetMode === 'create'" class="relative">
<input
data-dt-input
:value="columnDataTypes[sourceColumn] || ''"
:placeholder="t('tableImport.targetDataType')"
role="combobox"
aria-autocomplete="list"
:aria-expanded="activeDataTypeColumn === sourceColumn && dataTypePickerOpen"
aria-controls="table-import-data-type-options"
:aria-activedescendant="activeDataTypeColumn === sourceColumn && activeDataTypeOptionIndex >= 0 ? `table-import-data-type-option-${activeDataTypeOptionIndex}` : undefined"
class="h-7 w-full min-w-0 rounded-md border bg-background px-2 text-xs font-mono shadow-none hover:bg-muted/30 focus-visible:ring-1 focus-visible:ring-ring/25"
@focus="(event) => openDataTypePicker(sourceColumn, event.currentTarget as HTMLInputElement)"
@blur="closeDataTypePicker"
@keydown="(event) => handleDataTypePickerKeydown(event, sourceColumn, event.currentTarget as HTMLInputElement)"
@input="(e) => updateColumnDataType(sourceColumn, (e.target as HTMLInputElement).value)"
/>
</div>
</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-[320px] 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="mappingValidation.errors.length" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ mappingValidation.errors.join("; ") }}
</div>
<div v-else-if="requiredUnmappedColumns.length" class="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("tableImport.requiredUnmapped", { columns: requiredUnmappedColumns.join(", ") }) }}</span>
</div>
</div>
<div v-else-if="wizardStep === 'review'" class="space-y-3">
<div class="grid grid-cols-2 gap-3 text-xs">
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.target") }}</div>
<div class="truncate font-medium">{{ targetLabel }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.sourceFile") }}</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">{{ previewRowsLabel(preview) }}</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 || 0 }}</div>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div v-if="targetMode === 'existing'" 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>
<div v-if="targetMode === 'create' && createColumnSummaries.length" class="rounded-md border">
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.createColumns") }}</div>
<div class="max-h-40 overflow-auto">
<table class="min-w-full border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 bg-background">
<tr>
<th class="border-b border-r px-2 py-1.5 text-left font-medium">{{ t("tableImport.sourceColumn") }}</th>
<th class="border-b border-r px-2 py-1.5 text-left font-medium">{{ t("tableImport.targetColumn") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("tableImport.targetDataType") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="column in createColumnSummaries" :key="column.sourceColumn">
<td class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono">
<span class="block truncate">{{ column.sourceColumn }}</span>
</td>
<td class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono">
<span class="block truncate">{{ column.targetColumn }}</span>
</td>
<td class="max-w-[140px] border-b px-2 py-1.5 font-mono">
<span class="block truncate">{{ column.targetDataType }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="importMode === 'truncate'" class="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("tableImport.truncateWarning") }}</span>
</div>
</div>
<div v-else class="space-y-4">
<div class="rounded-md border px-4 py-5">
<div class="flex items-center gap-3">
<Square v-if="cancelling || progress?.status === 'cancelled'" class="h-5 w-5 fill-current text-destructive" />
<CheckCircle2 v-else-if="progress?.status === 'done'" class="h-5 w-5 text-emerald-600" />
<AlertTriangle v-else-if="progress?.status === 'error'" class="h-5 w-5 text-destructive" />
<Loader2 v-else-if="running" class="h-5 w-5 animate-spin text-primary" />
<FileUp v-else class="h-5 w-5 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ t(progressLabelKey) }}</div>
<div class="mt-1 text-xs text-muted-foreground tabular-nums">
<template v-if="progress?.totalRowsExact !== false && (progress?.totalRows ?? 0) > 0">{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? 0 }}</template>
<template v-else>{{ progress?.rowsImported ?? 0 }} {{ t("tableImport.rowsImported") }}</template>
· {{ progressPercent }}% ·
{{ t("tableImport.elapsed", { duration: formatTableImportElapsed(displayedElapsedMs) }) }}
</div>
</div>
</div>
<div class="mt-4 h-2 overflow-hidden rounded bg-muted">
<div class="h-full bg-primary transition-all" :style="{ width: `${progressPercent}%` }" />
</div>
</div>
<div v-if="errorMessage || progress?.error" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ errorMessage || progress?.error }}
</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 && wizardStep !== 'execution'" class="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{{ errorMessage }}</span>
<Button v-if="targetMode === 'existing' && !loadingTarget && !existingTargetMetadataReady" variant="outline" size="sm" class="h-6 px-2 text-xs" @click="loadTargetColumns()">
{{ t("tableImport.retry") }}
</Button>
</div>
</div>
<!-- 全局数据类型 popover:仅一个实例,替代每列的 SearchableSelect -->
<Teleport to="body">
<div v-if="activeDataTypeColumn && dataTypePickerOpen" id="table-import-data-type-options" role="listbox" :style="dataTypePickerStyle" class="pointer-events-auto z-[9999] max-h-48 overflow-auto rounded-md border bg-popover p-0.5 shadow-md">
<button
v-for="(opt, index) in dataTypePickerOptions"
:key="opt"
:id="`table-import-data-type-option-${index}`"
type="button"
role="option"
:aria-selected="opt === (activeDataTypeColumn ? columnDataTypes[activeDataTypeColumn] : '')"
class="flex h-7 w-full items-center rounded-sm px-2 text-left text-xs font-mono hover:bg-accent hover:text-accent-foreground"
:class="{ 'bg-accent/50': index === activeDataTypeOptionIndex || opt === (activeDataTypeColumn ? columnDataTypes[activeDataTypeColumn] : '') }"
@pointerenter="activeDataTypeOptionIndex = index"
@pointerdown.prevent="selectDataTypeOption(opt)"
>
{{ opt }}
</button>
</div>
</Teleport>
<DialogFooter class="shrink-0">
<Button variant="outline" :disabled="running" @click="open = false">
<X class="mr-1.5 h-3.5 w-3.5" />
{{ terminalStatus ? t("common.close") : t("dangerDialog.cancel") }}
</Button>
<Button v-if="canGoBack" variant="outline" @click="goBack">
<ArrowLeft class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.back") }}
</Button>
<Button v-if="wizardStep === 'source' || wizardStep === 'options' || wizardStep === 'mapping'" :disabled="!canGoNext || loadingPreview" @click="goNext">
<ArrowRight class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.next") }}
</Button>
<Button v-else-if="wizardStep === 'review'" :disabled="!canImport" @click="startImport">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.start") }}
</Button>
<Button v-else-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-if="progress?.status === 'done'" @click="open = false">
<Check class="mr-1.5 h-3.5 w-3.5" />
{{ t("common.done") }}
</Button>
</DialogFooter>
</DialogScrollContent>
</Dialog>
</template>