1504 lines
69 KiB
Vue
1504 lines
69 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { useToast } from "@/composables/useToast";
|
||
import {
|
||
autoMapImportColumns,
|
||
buildTableImportParseOptions,
|
||
formatTableImportElapsed,
|
||
nextTableImportWizardStep,
|
||
previousTableImportWizardStep,
|
||
requiredImportTargetColumns,
|
||
resolveTableImportElapsed,
|
||
suggestImportTargetDataTypes,
|
||
tableImportProgressPercent,
|
||
validateImportMappings,
|
||
type TableImportWizardStep,
|
||
} from "@/lib/table/tableImport";
|
||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||
import { 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 targetColumns = ref<ColumnInfo[]>([]);
|
||
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 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(true);
|
||
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 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" },
|
||
];
|
||
|
||
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 dataTypeOptions = computed(() => mergeDataTypeOptions(dynamicDataTypeOptions.value, getDataTypeOptions(structureDatabaseType.value), Object.values(columnDataTypes.value)));
|
||
const hasExistingTarget = computed(() => !!props.prefillTable);
|
||
const targetTableName = computed(() => (targetMode.value === "create" ? newTableName.value.trim() : props.prefillTable || ""));
|
||
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) 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;
|
||
if (wizardStep.value === "mapping") return 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++;
|
||
if (previewReloadTimer) {
|
||
clearTimeout(previewReloadTimer);
|
||
previewReloadTimer = null;
|
||
}
|
||
targetColumns.value = [];
|
||
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 = true;
|
||
selectedSheet.value = "";
|
||
jsonShape.value = "auto";
|
||
previewLimit.value = 50;
|
||
preview.value = null;
|
||
columnMapping.value = {};
|
||
columnDataTypes.value = {};
|
||
importMode.value = "append";
|
||
batchSize.value = 500;
|
||
loadingPreview.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";
|
||
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,
|
||
});
|
||
}
|
||
|
||
function importParseOptions(format: api.TableImportSourceFormat, currentPreview: api.TableImportPreview, sheetName = ""): api.TableImportParseOptions {
|
||
const options = taskParseOptions(format, sheetName);
|
||
if (isDelimitedFormat(format) && 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 loadTargetColumns() {
|
||
if (targetMode.value !== "existing" || !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 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);
|
||
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>();
|
||
try {
|
||
for (const source of sources) {
|
||
const format = detectFormat(sourceName(source));
|
||
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"] },
|
||
{ name: "Text", extensions: ["csv", "tsv", "txt"] },
|
||
{ name: "JSON", extensions: ["json"] },
|
||
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
|
||
],
|
||
});
|
||
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;
|
||
if (step === "review") return !!preview.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 (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: props.prefillSchema || "",
|
||
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: props.prefillSchema || "",
|
||
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) {
|
||
if (!isDelimitedFormat(task.format)) 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();
|
||
});
|
||
watch(targetMode, (mode) => {
|
||
if (mode === "existing") {
|
||
columnDataTypes.value = {};
|
||
dynamicDataTypeOptions.value = [];
|
||
void loadTargetColumns();
|
||
} else {
|
||
targetColumns.value = [];
|
||
importMode.value = "append";
|
||
applyAutoMapping();
|
||
applySuggestedColumnDataTypes();
|
||
void loadDataTypeOptions();
|
||
}
|
||
});
|
||
|
||
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" :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-5 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">{{ props.prefillTable || 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 class="flex h-8 items-center rounded-md border px-2 text-xs">
|
||
<span class="truncate">{{ props.prefillTable }}</span>
|
||
</div>
|
||
</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 === '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">
|
||
<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="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||
{{ errorMessage }}
|
||
</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>
|