feat(import-export): 优化数据表导入导出能力

- 修复表格格式导出时空值显示为 null 的问题
- 支持按列导出并保持字段顺序
- 支持导入列与数据库字段映射及冲突校验
- 隔离并发导入任务进度并完善元数据回退

Fixes #646
This commit is contained in:
Syngnat
2026-07-18 15:17:39 +08:00
parent f08bcb5e10
commit 15fba1d89f
27 changed files with 1911 additions and 133 deletions

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
normalizeDataExportDialogValues,
resolveDataExportColumns,
validateDataExportDialogValues,
type DataExportScopeOption,
} from './DataExportDialog';
const scopeOptions: DataExportScopeOption[] = [
{ value: 'all', label: 'All rows' },
];
describe('DataExportDialog column selection', () => {
it('selects every available column by default in source order', () => {
expect(resolveDataExportColumns(undefined, ['id', 'name', 'created_at'])).toEqual([
'id',
'name',
'created_at',
]);
expect(normalizeDataExportDialogValues(
scopeOptions,
{ format: 'csv', scope: 'all' },
false,
['id', 'name', 'created_at'],
).columns).toEqual(['id', 'name', 'created_at']);
});
it('drops unknown and duplicate selections while preserving available column order', () => {
expect(resolveDataExportColumns(
['created_at', 'missing', 'id', 'created_at'],
['id', 'name', 'created_at'],
)).toEqual(['id', 'created_at']);
});
it('requires at least one column when columns are available', () => {
const values = normalizeDataExportDialogValues(
scopeOptions,
{ format: 'csv', scope: 'all', columns: [] },
false,
['id', 'name'],
);
expect(validateDataExportDialogValues(values, scopeOptions, false, ['id', 'name'])).toBeTruthy();
expect(validateDataExportDialogValues(
{ ...values, columns: ['name'] },
scopeOptions,
false,
['id', 'name'],
)).toBeNull();
});
it('keeps legacy exports without a column catalog unrestricted', () => {
const values = normalizeDataExportDialogValues(
scopeOptions,
{ format: 'csv', scope: 'all' },
false,
);
expect(values.columns).toBeUndefined();
expect(validateDataExportDialogValues(values, scopeOptions)).toBeNull();
});
});

View File

@@ -9,6 +9,7 @@ export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
export type DataExportFileOptions = {
format: DataExportFormat;
columns?: string[];
xlsxMaxRowsPerSheet?: number;
insertSQLDialect?: string;
insertSQLTargetTable?: string;
@@ -31,6 +32,7 @@ export type DataExportScopeOption = {
export type ShowDataExportDialogOptions = {
title: string;
scopeOptions: DataExportScopeOption[];
availableColumns?: string[];
initialValues?: Partial<DataExportDialogValues>;
allowInsertSql?: boolean;
okText?: string;
@@ -66,10 +68,30 @@ const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
};
const normalizeDialogValues = (
export const resolveDataExportColumns = (
requestedColumns: string[] | undefined,
availableColumns: string[] | undefined,
): string[] | undefined => {
if (!Array.isArray(availableColumns)) return undefined;
const seenAvailable = new Set<string>();
const normalizedAvailable = availableColumns.filter((column) => {
const value = String(column ?? '');
if (!value.trim() || seenAvailable.has(value)) return false;
seenAvailable.add(value);
return true;
});
if (requestedColumns === undefined) return normalizedAvailable;
const requested = new Set(requestedColumns.map((column) => String(column ?? '')));
return normalizedAvailable.filter((column) => requested.has(column));
};
export const normalizeDataExportDialogValues = (
scopeOptions: DataExportScopeOption[],
initialValues?: Partial<DataExportDialogValues>,
allowInsertSql = false,
availableColumns?: string[],
): DataExportDialogValues => {
const requestedFormat = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
const format = resolveFormatOptions(allowInsertSql).some((item) => item.value === requestedFormat)
@@ -79,17 +101,20 @@ const normalizeDialogValues = (
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
: DEFAULT_XLSX_ROWS_PER_SHEET;
const columns = resolveDataExportColumns(initialValues?.columns, availableColumns);
return {
format,
scope,
xlsxMaxRowsPerSheet,
...(columns === undefined ? {} : { columns }),
};
};
const validateDialogValues = (
export const validateDataExportDialogValues = (
values: DataExportDialogValues,
scopeOptions: DataExportScopeOption[],
allowInsertSql = false,
availableColumns?: string[],
): string | null => {
if (!resolveFormatOptions(allowInsertSql).some((item) => item.value === values.format)) {
return t('data_export.dialog.validation.format_required');
@@ -100,6 +125,9 @@ const validateDialogValues = (
return t('data_export.dialog.validation.scope_required');
}
}
if (Array.isArray(availableColumns) && (!Array.isArray(values.columns) || values.columns.length === 0)) {
return t('data_export.dialog.validation.columns_required');
}
if (values.format === 'xlsx') {
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
if (!Number.isFinite(rows) || rows <= 0) {
@@ -116,12 +144,25 @@ const validateDialogValues = (
const DataExportDialogContent: React.FC<{
scopeOptions: DataExportScopeOption[];
availableColumns?: string[];
initialValues?: Partial<DataExportDialogValues>;
allowInsertSql?: boolean;
onChange: (values: DataExportDialogValues) => void;
}> = ({ scopeOptions, initialValues, allowInsertSql = false, onChange }) => {
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues, allowInsertSql));
}> = ({ scopeOptions, availableColumns, initialValues, allowInsertSql = false, onChange }) => {
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDataExportDialogValues(
scopeOptions,
initialValues,
allowInsertSql,
availableColumns,
));
const formatOptions = useMemo(() => resolveFormatOptions(allowInsertSql), [allowInsertSql]);
const columnOptions = useMemo(
() => (resolveDataExportColumns(undefined, availableColumns) || []).map((column) => ({
value: column,
label: column,
})),
[availableColumns],
);
useEffect(() => {
onChange(values);
@@ -162,6 +203,25 @@ const DataExportDialogContent: React.FC<{
</div>
)}
{Array.isArray(availableColumns) && (
<Form.Item
label={t('data_export.dialog.field.columns')}
extra={t('data_export.dialog.field.columns_help')}
style={{ marginBottom: 16 }}
>
<Select
mode="multiple"
value={values.columns || []}
options={columnOptions}
maxTagCount="responsive"
onChange={(columns) => setValues((prev) => ({
...prev,
columns: resolveDataExportColumns(columns, availableColumns) || [],
}))}
/>
</Form.Item>
)}
{values.format === 'xlsx' && (
<Form.Item
label={t('data_export.dialog.field.xlsx_max_rows')}
@@ -195,7 +255,12 @@ export async function showDataExportDialog(
options: ShowDataExportDialogOptions,
): Promise<DataExportDialogValues | null> {
const allowInsertSql = options.allowInsertSql === true;
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues, allowInsertSql);
const initialValues = normalizeDataExportDialogValues(
options.scopeOptions,
options.initialValues,
allowInsertSql,
options.availableColumns,
);
return new Promise((resolve) => {
let resolved = false;
@@ -218,6 +283,7 @@ export async function showDataExportDialog(
content: (
<DataExportDialogContent
scopeOptions={options.scopeOptions}
availableColumns={options.availableColumns}
initialValues={initialValues}
allowInsertSql={allowInsertSql}
onChange={(values) => {
@@ -226,7 +292,12 @@ export async function showDataExportDialog(
/>
),
onOk: async () => {
const errorMessage = validateDialogValues(latestValues, options.scopeOptions, allowInsertSql);
const errorMessage = validateDataExportDialogValues(
latestValues,
options.scopeOptions,
allowInsertSql,
options.availableColumns,
);
if (errorMessage) {
void message.error(errorMessage);
throw new Error(errorMessage);

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const actionsSource = readFileSync(new URL('./useDataGridV2Actions.ts', import.meta.url), 'utf8');
const gridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
describe('DataGrid export columns', () => {
it('offers result columns in the export dialog and forwards the selected order', () => {
expect(actionsSource).toContain('availableColumns: displayOutputColumnNames');
expect(actionsSource).toContain('columns: values.columns');
});
it('uses selected columns for local row projection and backend ExportData arguments', () => {
expect(gridSource).toContain('resolveDataExportColumns(options.columns, displayOutputColumnNames)');
expect(gridSource).toContain('pickDataGridOutputRows(rows, exportColumns)');
expect(gridSource).toMatch(/ExportDataWithOptions\(\s*cleanRows,\s*exportColumns,/);
});
});

View File

@@ -142,6 +142,7 @@ import { buildDataGridTransactionLog } from './dataGridTransactionLog';
import {
DEFAULT_DATA_EXPORT_FORMAT,
DEFAULT_XLSX_ROWS_PER_SHEET,
resolveDataExportColumns,
showDataExportDialog,
type DataExportDialogValues,
type DataExportFileOptions,
@@ -1079,7 +1080,9 @@ const DataGrid: React.FC<DataGridProps> = ({
// Helper to export specific data
const exportData = async (rows: any[], options: DataExportFileOptions) => {
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
const exportColumns = resolveDataExportColumns(options.columns, displayOutputColumnNames)
|| displayOutputColumnNames;
const cleanRows = pickDataGridOutputRows(rows, exportColumns);
const exportTitle = String(tableName || '').trim()
? translateDataGrid('file.backend.dialog.export_table', { table: tableName })
: translateDataGrid('file.backend.dialog.export_data');
@@ -1090,7 +1093,7 @@ const DataGrid: React.FC<DataGridProps> = ({
totalRows: cleanRows.length,
run: (jobId) => ExportDataWithOptions(
cleanRows,
displayOutputColumnNames,
exportColumns,
tableName || 'export',
{
...buildBackendExportOptions(options),

View File

@@ -8,7 +8,8 @@ import ImportPreviewModal from "./ImportPreviewModal";
const mocks = vi.hoisted(() => ({
previewImportFile: vi.fn(),
importDataWithProgress: vi.fn(),
dbGetColumns: vi.fn(),
importDataWithProgressOptions: vi.fn(),
progressHandler: null as ((data: any) => void) | null,
eventsOn: vi.fn((_event: string, handler: (data: any) => void) => {
mocks.progressHandler = handler;
@@ -44,7 +45,8 @@ vi.mock("../i18n/runtime", () => ({
vi.mock("../../wailsjs/go/app/App", () => ({
PreviewImportFile: mocks.previewImportFile,
ImportDataWithProgress: mocks.importDataWithProgress,
DBGetColumns: mocks.dbGetColumns,
ImportDataWithProgressOptions: mocks.importDataWithProgressOptions,
}));
vi.mock("../../wailsjs/runtime/runtime", () => ({
@@ -108,10 +110,29 @@ vi.mock("antd", async () => {
Button: ({
children,
onClick,
disabled,
}: {
children?: React.ReactNode;
onClick?: () => void;
}) => React.createElement("button", { onClick }, children),
disabled?: boolean;
}) => React.createElement("button", { onClick, disabled }, children),
Select: ({
value,
options,
onChange,
}: {
value?: string;
options?: Array<{ value: string; label: React.ReactNode; disabled?: boolean }>;
onChange?: (value: string) => void;
}) => React.createElement(
"select",
{ value, onChange: (event: any) => onChange?.(event.target.value) },
options?.map((option) => React.createElement(
"option",
{ key: option.value, value: option.value, disabled: option.disabled },
option.label,
)),
),
Space: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
};
@@ -134,22 +155,24 @@ const textContent = (node: any): string => {
return textContent(node.children || []);
};
const renderImportPreview = async () => {
const createImportPreviewTree = (filePath = "D:/imports/users.csv") => (
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
<ImportPreviewModal
visible
filePath={filePath}
connectionId="conn-1"
dbName="app"
tableName="users"
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
</I18nProvider>
);
const renderImportPreview = async (filePath = "D:/imports/users.csv") => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
<ImportPreviewModal
visible
filePath="D:/imports/users.csv"
connectionId="conn-1"
dbName="app"
tableName="users"
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
</I18nProvider>,
);
renderer = create(createImportPreviewTree(filePath));
await Promise.resolve();
await Promise.resolve();
});
@@ -158,6 +181,20 @@ const renderImportPreview = async () => {
describe("ImportPreviewModal i18n", () => {
beforeEach(() => {
mocks.storeState.connections = [
{
id: "conn-1",
config: {
type: "mysql",
host: "localhost",
port: 3306,
user: "root",
password: "",
database: "app",
},
},
];
mocks.previewImportFile.mockReset();
mocks.previewImportFile.mockResolvedValue({
success: true,
data: {
@@ -166,7 +203,17 @@ describe("ImportPreviewModal i18n", () => {
previewRows: [{ id: 1, user_name: "alice" }],
},
});
mocks.importDataWithProgress.mockReset();
mocks.dbGetColumns.mockReset();
mocks.dbGetColumns.mockResolvedValue({
success: true,
data: [
{ name: "ID", type: "bigint" },
{ name: "username", type: "varchar" },
{ name: "email", type: "varchar" },
],
});
mocks.importDataWithProgressOptions.mockReset();
mocks.progressHandler = null;
mocks.eventsOn.mockClear();
mocks.eventsOff.mockClear();
});
@@ -205,7 +252,7 @@ describe("ImportPreviewModal i18n", () => {
it("keeps preview total when progress events omit total rows", async () => {
let resolveImport!: (value: any) => void;
mocks.importDataWithProgress.mockImplementation(
mocks.importDataWithProgressOptions.mockImplementation(
() =>
new Promise((resolve) => {
resolveImport = resolve;
@@ -224,9 +271,18 @@ describe("ImportPreviewModal i18n", () => {
});
expect(mocks.progressHandler).toBeTypeOf("function");
const importJobId = mocks.importDataWithProgressOptions.mock.calls[0][4].jobId;
await act(async () => {
mocks.progressHandler?.({
jobId: "another-import-job",
current: 9,
total: 12,
success: 9,
errors: 0,
});
mocks.progressHandler?.({
jobId: importJobId,
current: 3,
total: 0,
success: 3,
@@ -247,4 +303,221 @@ describe("ImportPreviewModal i18n", () => {
await Promise.resolve();
});
});
it("maps file headers to database fields and submits only selected mappings", async () => {
mocks.importDataWithProgressOptions.mockResolvedValue({
success: true,
data: { success: 12, failed: 0, total: 12, errorLogs: [] },
});
const renderer = await renderImportPreview();
const selects = renderer.root.findAllByType("select");
expect(selects).toHaveLength(2);
expect(selects[0].props.value).toBe("ID");
expect(selects[1].props.value).toBe("");
await act(async () => {
selects[1].props.onChange({ target: { value: "username" } });
await Promise.resolve();
});
const button = renderer.root
.findAllByType("button")
.find((node) => textContent(node.props.children) === "Start import");
expect(button?.props.disabled).toBe(false);
await act(async () => {
button?.props.onClick();
await Promise.resolve();
});
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledWith(
expect.objectContaining({ type: "mysql" }),
"app",
"users",
"D:/imports/users.csv",
{
columnMappings: { id: "ID", user_name: "username" },
jobId: expect.stringMatching(/^import-/),
},
);
expect(mocks.dbGetColumns).toHaveBeenCalledWith(
expect.objectContaining({ type: "mysql" }),
"app",
"users",
);
});
it("disables import until at least one source column is mapped", async () => {
mocks.previewImportFile.mockResolvedValue({
success: true,
data: {
columns: ["legacy_name"],
totalRows: 1,
previewRows: [{ legacy_name: "alice" }],
},
});
const renderer = await renderImportPreview();
const button = renderer.root
.findAllByType("button")
.find((node) => textContent(node.props.children) === "Start import");
expect(button?.props.disabled).toBe(true);
expect(textContent(renderer.toJSON())).toContain("Map at least one file column");
});
it("ignores stale preview responses after switching files", async () => {
let resolveFirstPreview!: (value: any) => void;
mocks.previewImportFile
.mockImplementationOnce(() => new Promise((resolve) => {
resolveFirstPreview = resolve;
}))
.mockResolvedValueOnce({
success: true,
data: {
columns: ["email"],
totalRows: 1,
previewRows: [{ email: "new@example.com" }],
},
});
const renderer = await renderImportPreview("D:/imports/old.csv");
await act(async () => {
renderer.update(createImportPreviewTree("D:/imports/new.csv"));
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer.toJSON())).toContain("new@example.com");
await act(async () => {
resolveFirstPreview({
success: true,
data: {
columns: ["user_name"],
totalRows: 1,
previewRows: [{ user_name: "stale-user" }],
},
});
await Promise.resolve();
await Promise.resolve();
});
const renderedText = textContent(renderer.toJSON());
expect(renderedText).toContain("new@example.com");
expect(renderedText).not.toContain("stale-user");
});
it("ignores blank file headers when building column mappings", async () => {
mocks.previewImportFile.mockResolvedValue({
success: true,
data: {
columns: ["", "id", " "],
totalRows: 1,
previewRows: [{ id: 1 }],
},
});
const renderer = await renderImportPreview();
const selects = renderer.root.findAllByType("select");
expect(selects).toHaveLength(1);
expect(selects[0].props.value).toBe("ID");
});
it("keeps a pending import locked and preserves partial failures when connection state changes", async () => {
let resolveImport!: (value: any) => void;
mocks.importDataWithProgressOptions.mockImplementation(
() => new Promise((resolve) => {
resolveImport = resolve;
}),
);
const renderer = await renderImportPreview();
const startButton = renderer.root
.findAllByType("button")
.find((node) => textContent(node.props.children) === "Start import");
await act(async () => {
startButton?.props.onClick();
await Promise.resolve();
});
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledTimes(1);
mocks.storeState.connections = mocks.storeState.connections.map((item) => ({
...item,
config: { ...item.config, host: "changed-host" },
}));
await act(async () => {
renderer.update(createImportPreviewTree());
await Promise.resolve();
});
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledTimes(1);
expect(textContent(renderer.toJSON())).toContain("Importing data");
expect(textContent(renderer.toJSON())).not.toContain("Start import");
await act(async () => {
resolveImport({
success: true,
data: {
success: 11,
failed: 1,
total: 12,
errorLogs: ["Row 12: duplicate key"],
},
});
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.eventsOff).not.toHaveBeenCalled();
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
expect(textContent(renderer.toJSON())).toContain("Failed 1 rows");
expect(textContent(renderer.toJSON())).toContain("Row 12: duplicate key");
});
it("preserves an RPC failure when connection state changes during import", async () => {
let resolveImport!: (value: any) => void;
mocks.importDataWithProgressOptions.mockImplementation(
() => new Promise((resolve) => {
resolveImport = resolve;
}),
);
const renderer = await renderImportPreview();
const startButton = renderer.root
.findAllByType("button")
.find((node) => textContent(node.props.children) === "Start import");
await act(async () => {
startButton?.props.onClick();
await Promise.resolve();
});
mocks.storeState.connections = mocks.storeState.connections.map((item) => ({
...item,
config: { ...item.config, host: "changed-host" },
}));
await act(async () => {
renderer.update(createImportPreviewTree());
await Promise.resolve();
});
await act(async () => {
resolveImport({ success: false, message: "database rejected import" });
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
expect(textContent(renderer.toJSON())).toContain("database rejected import");
});
it("keeps large column mapping lists independently scrollable", () => {
const source = readFileSync(
new URL("./ImportPreviewModal.tsx", import.meta.url),
"utf8",
);
expect(source).toContain('data-import-column-mapping-list="true"');
expect(source).toContain('maxHeight: 240, overflowY: "auto"');
expect(source).toContain('closable={!importing}');
expect(source).toContain('maskClosable={!importing}');
expect(source).toContain('keyboard={!importing}');
});
});

View File

@@ -1,16 +1,18 @@
import Modal from './common/ResizableDraggableModal';
import React, { useState, useEffect } from "react";
import { Table, Alert, Progress, Button, Space } from 'antd';
import React, { useState, useEffect, useRef } from "react";
import { Table, Alert, Progress, Button, Space, Select } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
import {
DBGetColumns,
PreviewImportFile,
ImportDataWithProgress,
ImportDataWithProgressOptions,
} from "../../wailsjs/go/app/App";
import { EventsOn, EventsOff } from "../../wailsjs/runtime/runtime";
import { EventsOn } from "../../wailsjs/runtime/runtime";
import { useStore } from "../store";
import { t as defaultTranslate } from "../i18n";
import { useOptionalI18n } from "../i18n/provider";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { getColumnDefinitionName } from "../utils/columnDefinition";
interface ImportPreviewModalProps {
visible: boolean;
filePath: string;
@@ -28,6 +30,7 @@ interface PreviewData {
}
interface ImportProgress {
jobId?: string;
current: number;
total: number;
success: number;
@@ -35,6 +38,13 @@ interface ImportProgress {
totalRowsKnown?: boolean;
}
const createImportJobId = (): string => {
if (typeof globalThis.crypto?.randomUUID === "function") {
return `import-${globalThis.crypto.randomUUID()}`;
}
return `import-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
};
const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
visible,
filePath,
@@ -47,24 +57,44 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const connections = useStore((state) => state.connections);
const darkMode = useStore((state) => state.theme === "dark");
const connection = connections.find((item) => item.id === connectionId);
const [loading, setLoading] = useState(true);
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
const [targetColumns, setTargetColumns] = useState<string[]>([]);
const [columnMappings, setColumnMappings] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [importResult, setImportResult] = useState<any>(null);
const previewRequestRef = useRef(0);
const importRequestRef = useRef(0);
const importingRef = useRef(false);
const activeImportJobIdRef = useRef("");
const previewConnectionConfigRef = useRef<any>(null);
const secondaryTextColor = darkMode ? "rgba(255,255,255,0.65)" : "rgba(0,0,0,0.45)";
const mappingFieldBackground = darkMode ? "rgba(255,255,255,0.06)" : "#f5f5f5";
useEffect(() => {
if (importingRef.current) return undefined;
const requestId = previewRequestRef.current + 1;
previewRequestRef.current = requestId;
if (visible && filePath) {
loadPreview();
void loadPreview(requestId);
}
}, [visible, filePath]);
return () => {
if (previewRequestRef.current === requestId) {
previewRequestRef.current += 1;
}
};
}, [visible, filePath, connectionId, dbName, tableName, connection]);
useEffect(() => {
if (importing) {
const unsubscribe = EventsOn(
"import:progress",
(data: ImportProgress) => {
if (!data || data.jobId !== activeImportJobIdRef.current) return;
setProgress((prev) => {
const fallbackTotal = prev?.total || previewData?.totalRows || 0;
const nextTotal =
@@ -83,53 +113,27 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
);
return () => {
unsubscribe?.();
EventsOff("import:progress");
};
}
}, [importing, previewData?.totalRows]);
const loadPreview = async () => {
const loadPreview = async (requestId: number) => {
importRequestRef.current += 1;
importingRef.current = false;
activeImportJobIdRef.current = "";
previewConnectionConfigRef.current = null;
setImporting(false);
setLoading(true);
setError(null);
try {
const res = await PreviewImportFile(filePath);
if (res.success && res.data) {
setPreviewData({
columns: res.data.columns || [],
totalRows: res.data.totalRows || 0,
previewRows: res.data.previewRows || [],
});
} else {
setError(res.message || t("import_preview.error.preview_failed"));
}
} catch (e: any) {
setError(
t("import_preview.error.preview_failed_detail", {
detail: String(e?.message || e),
}),
);
} finally {
setLoading(false);
}
};
const handleImport = async () => {
if (!previewData) return;
setImporting(true);
setProgress({
current: 0,
total: previewData.totalRows,
success: 0,
errors: 0,
});
setPreviewData(null);
setTargetColumns([]);
setColumnMappings({});
setImportResult(null);
setProgress(null);
try {
const conn = connections.find((c) => c.id === connectionId);
const conn = connection;
if (!conn) {
setError(t("import_preview.error.connection_config_not_found"));
setImporting(false);
return;
}
@@ -147,13 +151,112 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
keyPath: "",
},
};
const rpcConfig = buildRpcConnectionConfig(config) as any;
const [previewRes, columnsRes] = await Promise.all([
PreviewImportFile(filePath),
DBGetColumns(rpcConfig, dbName, tableName),
]);
if (previewRequestRef.current !== requestId) return;
if (!previewRes.success || !previewRes.data) {
setError(previewRes.message || t("import_preview.error.preview_failed"));
return;
}
if (!columnsRes.success || !Array.isArray(columnsRes.data)) {
setError(columnsRes.message || t("import_preview.error.target_columns_failed"));
return;
}
const res = await ImportDataWithProgress(
previewConnectionConfigRef.current = config;
const sourceColumns: string[] = Array.isArray(previewRes.data.columns)
? previewRes.data.columns
.map((column: unknown) => String(column))
.filter((column: string) => column.trim().length > 0)
: [];
const nextTargetColumns = Array.from(new Set(
columnsRes.data.map(getColumnDefinitionName).filter(Boolean),
));
const targetsByLowerName = new Map<string, string[]>();
nextTargetColumns.forEach((column) => {
const key = column.toLowerCase();
targetsByLowerName.set(key, [...(targetsByLowerName.get(key) || []), column]);
});
const nextMappings: Record<string, string> = {};
sourceColumns.forEach((sourceColumn) => {
const exactTarget = nextTargetColumns.find((targetColumn) => targetColumn === sourceColumn);
const insensitiveTargets = targetsByLowerName.get(sourceColumn.toLowerCase()) || [];
nextMappings[sourceColumn] = exactTarget || (insensitiveTargets.length === 1 ? insensitiveTargets[0] : "");
});
setPreviewData({
columns: sourceColumns,
totalRows: previewRes.data.totalRows || 0,
previewRows: previewRes.data.previewRows || [],
});
setTargetColumns(nextTargetColumns);
setColumnMappings(nextMappings);
} catch (e: any) {
if (previewRequestRef.current !== requestId) return;
setError(
t("import_preview.error.preview_failed_detail", {
detail: String(e?.message || e),
}),
);
} finally {
if (previewRequestRef.current === requestId) {
setLoading(false);
}
}
};
const mappedTargetColumns = Object.values(columnMappings).filter(Boolean);
const hasDuplicateSourceColumns = previewData
? new Set(previewData.columns).size !== previewData.columns.length
: false;
const hasDuplicateTargetColumns = new Set(mappedTargetColumns).size !== mappedTargetColumns.length;
const mappingValidationError = hasDuplicateSourceColumns
? t("import_preview.mapping.validation.duplicate_source")
: hasDuplicateTargetColumns
? t("import_preview.mapping.validation.duplicate_target")
: mappedTargetColumns.length === 0
? t("import_preview.mapping.validation.required")
: null;
const handleImport = async () => {
if (!previewData || mappingValidationError) return;
const importRequestId = importRequestRef.current + 1;
const importJobId = createImportJobId();
importRequestRef.current = importRequestId;
importingRef.current = true;
activeImportJobIdRef.current = importJobId;
setImporting(true);
setProgress({
current: 0,
total: previewData.totalRows,
success: 0,
errors: 0,
});
setImportResult(null);
try {
const config = previewConnectionConfigRef.current;
if (!config) {
setError(t("import_preview.error.connection_config_not_found"));
return;
}
const selectedMappings = Object.fromEntries(
Object.entries(columnMappings).filter(([, targetColumn]) => Boolean(targetColumn)),
);
const res = await ImportDataWithProgressOptions(
buildRpcConnectionConfig(config) as any,
dbName,
tableName,
filePath,
{ columnMappings: selectedMappings, jobId: importJobId },
);
if (importRequestRef.current !== importRequestId) return;
if (res.success && res.data) {
setImportResult(res.data);
@@ -164,13 +267,18 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
setError(res.message || t("import_preview.error.import_failed"));
}
} catch (e: any) {
if (importRequestRef.current !== importRequestId) return;
setError(
t("import_preview.error.import_failed_detail", {
detail: String(e?.message || e),
}),
);
} finally {
setImporting(false);
if (importRequestRef.current === importRequestId) {
importingRef.current = false;
activeImportJobIdRef.current = "";
setImporting(false);
}
}
};
@@ -192,7 +300,12 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
<Modal
title={t("import_preview.title")}
open={visible}
onCancel={onClose}
onCancel={() => {
if (!importing) onClose();
}}
closable={!importing}
maskClosable={!importing}
keyboard={!importing}
width={900}
footer={
importResult ? (
@@ -205,7 +318,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
<Button
type="primary"
onClick={handleImport}
disabled={!previewData || loading}
disabled={!previewData || loading || Boolean(mappingValidationError)}
>
{t("import_preview.action.start")}
</Button>
@@ -247,12 +360,75 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
style={{
marginBottom: 16,
padding: 8,
background: "#f5f5f5",
background: mappingFieldBackground,
borderRadius: 4,
}}
>
{previewData.columns.join(", ")}
</div>
<div data-import-column-mapping="true" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>
{t("import_preview.mapping.title")}
</div>
<div style={{ marginBottom: 10, color: secondaryTextColor, fontSize: 12 }}>
{t("import_preview.mapping.description")}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
gap: 8,
marginBottom: 6,
color: darkMode ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.65)",
fontSize: 12,
fontWeight: 600,
}}
>
<span>{t("import_preview.mapping.source_column")}</span>
<span>{t("import_preview.mapping.target_column")}</span>
</div>
<div
data-import-column-mapping-list="true"
style={{ maxHeight: 240, overflowY: "auto", paddingRight: 4 }}
>
{previewData.columns.map((sourceColumn) => (
<div
key={sourceColumn}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
gap: 8,
alignItems: "center",
marginBottom: 8,
}}
>
<div title={sourceColumn} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{sourceColumn}
</div>
<Select
value={columnMappings[sourceColumn] || ""}
options={[
{ value: "", label: t("import_preview.mapping.ignore") },
...targetColumns.map((targetColumn) => ({
value: targetColumn,
label: targetColumn,
disabled: mappedTargetColumns.includes(targetColumn)
&& columnMappings[sourceColumn] !== targetColumn,
})),
]}
onChange={(targetColumn) => setColumnMappings((current) => ({
...current,
[sourceColumn]: targetColumn,
}))}
style={{ width: "100%" }}
/>
</div>
))}
</div>
{mappingValidationError && (
<Alert type="warning" showIcon message={mappingValidationError} />
)}
</div>
<div style={{ marginBottom: 8, fontWeight: 600 }}>
{t("import_preview.preview.table_title")}
</div>

View File

@@ -3,7 +3,10 @@ import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench';
import TableExportWorkbench, {
buildTableExportHistoryEntry,
resolveTableExportColumnNames,
} from './TableExportWorkbench';
import { setCurrentLanguage } from '../i18n';
import type { ExportProgressState } from './useExportProgressRunner';
@@ -283,6 +286,27 @@ describe('TableExportWorkbench', () => {
expect(source).toContain("t('data_export.label.elapsed')");
});
it('normalizes table column metadata without changing database order', () => {
expect(resolveTableExportColumnNames([
{ Name: 'id' },
{ name: 'display_name' },
{ COLUMN_NAME: 'created_at' },
{ name: 'display_name' },
{ name: '' },
])).toEqual(['id', 'display_name', 'created_at']);
});
it('loads selectable columns and sends the selection through both single-table export paths', () => {
const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8');
expect(source).toContain('DBGetColumns(');
expect(source).toContain('mode="multiple"');
expect(source).toContain('columns: selectedColumns');
expect(source).toContain('selectedColumns.length > 0');
expect(source.match(/ExportQueryWithOptions\(/g)).toHaveLength(2);
expect(source).toContain('ExportTableWithOptions(');
});
it('prefers backend startedAt over a placeholder history timestamp for the same job', () => {
const entry = buildTableExportHistoryEntry({
progressState: {

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, InputNumber, Select, Tooltip, Typography } from 'antd';
import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons';
import {
DBGetColumns,
DBGetDatabases,
DBGetTables,
ExportDatabasesSQLWithOptions,
@@ -18,6 +19,7 @@ import type {
TableExportScopeOption,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getColumnDefinitionName } from '../utils/columnDefinition';
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
import { buildExportWorkbenchHistoryKey } from '../utils/tableExportTab';
import {
@@ -32,6 +34,7 @@ import {
DEFAULT_DATA_EXPORT_FORMAT,
DEFAULT_XLSX_ROWS_PER_SHEET,
MAX_XLSX_ROWS_PER_SHEET,
resolveDataExportColumns,
type DataExportFormat,
} from './DataExportDialog';
import ExportProgressBar from './ExportProgressBar';
@@ -111,6 +114,19 @@ const resolveInitialScope = (
return scopeOptions.find((item) => !item.disabled)?.value || 'all';
};
export const resolveTableExportColumnNames = (definitions: unknown): string[] => {
if (!Array.isArray(definitions)) return [];
const seen = new Set<string>();
const columns: string[] = [];
definitions.forEach((definition) => {
const column = getColumnDefinitionName(definition);
if (!column || seen.has(column)) return;
seen.add(column);
columns.push(column);
});
return columns;
};
const normalizeConnectionConfig = (connection: SavedConnection) => ({
...connection.config,
port: Number(connection.config.port),
@@ -286,14 +302,18 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const [selectedDbName, setSelectedDbName] = useState(() => String(tab.dbName || '').trim());
const [availableDatabases, setAvailableDatabases] = useState<SelectOption[]>([]);
const [availableObjects, setAvailableObjects] = useState<SelectOption[]>([]);
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [selectedObjectNames, setSelectedObjectNames] = useState<string[]>([]);
const [selectedDatabaseNames, setSelectedDatabaseNames] = useState<string[]>([]);
const [batchTableMode, setBatchTableMode] = useState<BatchTableExportMode>('schema');
const [batchDatabaseMode, setBatchDatabaseMode] = useState<BatchDatabaseExportMode>('schema');
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [loadingObjects, setLoadingObjects] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [databaseLoadError, setDatabaseLoadError] = useState('');
const [objectLoadError, setObjectLoadError] = useState('');
const [columnLoadError, setColumnLoadError] = useState('');
const effectiveConnectionId = isSingleWorkbench ? String(tab.connectionId || '').trim() : selectedConnectionId;
const effectiveDbName = isSingleWorkbench ? String(tab.dbName || '').trim() : selectedDbName;
@@ -360,6 +380,50 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}, [scopeOptions, tab.tableExportInitialScope]);
useEffect(() => {
const objectName = String(tab.tableName || '').trim();
if (!isSingleWorkbench || !connectionConfig || !objectName) {
setAvailableColumns([]);
setSelectedColumns([]);
setColumnLoadError('');
setLoadingColumns(false);
return undefined;
}
let alive = true;
setLoadingColumns(true);
setColumnLoadError('');
DBGetColumns(buildRpcConnectionConfig(connectionConfig) as any, effectiveDbName, objectName)
.then((res) => {
if (!alive) return;
if (!res.success) {
setAvailableColumns([]);
setSelectedColumns([]);
setColumnLoadError(res.message || t('data_export.message.load_columns_failed'));
return;
}
const nextColumns = resolveTableExportColumnNames(res.data);
setAvailableColumns(nextColumns);
setSelectedColumns(nextColumns);
if (nextColumns.length === 0) {
setColumnLoadError(t('data_export.message.load_columns_failed'));
}
})
.catch((error: any) => {
if (!alive) return;
setAvailableColumns([]);
setSelectedColumns([]);
setColumnLoadError(error?.message || t('data_export.message.load_columns_failed'));
})
.finally(() => {
if (alive) setLoadingColumns(false);
});
return () => {
alive = false;
};
}, [connectionConfig, effectiveDbName, isSingleWorkbench, tab.tableName]);
useEffect(() => {
if (!progressState.startedAt || progressState.finishedAt > 0) return undefined;
const timer = window.setInterval(() => {
@@ -588,7 +652,12 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
return false;
}
if (isSingleWorkbench) {
return !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery);
return !!tab.tableName
&& !!scope
&& !activeScopeOption?.disabled
&& !loadingColumns
&& selectedColumns.length > 0
&& (scope === 'all' || !!activeScopeQuery);
}
if (isBatchTablesWorkbench) {
return !!selectedDbName && selectedObjectNames.length > 0;
@@ -601,9 +670,11 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
isBatchTablesWorkbench,
isRunning,
isSingleWorkbench,
loadingColumns,
scope,
selectedDatabaseNames.length,
selectedDbName,
selectedColumns.length,
selectedObjectNames.length,
tab.tableName,
]);
@@ -624,6 +695,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
run: (jobId) => {
const options = {
format,
columns: selectedColumns,
xlsxMaxRowsPerSheet,
jobId,
totalRowsHint: singleTotalRowsKnown ? singleScopeRowCount : 0,
@@ -910,6 +982,15 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
/>
) : null}
{columnLoadError ? (
<Alert
type="error"
showIcon
message={t('data_export.dialog.field.columns')}
description={columnLoadError}
/>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{isSingleWorkbench ? (
<>
@@ -942,6 +1023,24 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.dialog.field.columns')}</div>
<Select
style={{ width: '100%' }}
mode="multiple"
value={selectedColumns}
loading={loadingColumns}
options={availableColumns.map((column) => ({ value: column, label: column }))}
maxTagCount="responsive"
onChange={(columns) => setSelectedColumns(
resolveDataExportColumns(columns, availableColumns) || [],
)}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{t('data_export.dialog.field.columns_help')}
</div>
</div>
{format === 'xlsx' ? (
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.xlsx_max_rows')}</div>

View File

@@ -863,6 +863,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
const values = await showDataExportDialog(modal, {
title: translateDataGrid('file.backend.dialog.export_query_result'),
scopeOptions,
availableColumns: displayOutputColumnNames,
allowInsertSql: canExportInsertSQL,
initialValues: {
...commonInitialValues,
@@ -870,7 +871,10 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
},
});
if (!values) return;
await exportQueryResultRows(values, values.scope as Exclude<DataGridExportScope, 'filteredAll'>);
await exportQueryResultRows(
{ ...values, columns: values.columns },
values.scope as Exclude<DataGridExportScope, 'filteredAll'>,
);
return;
}
@@ -937,6 +941,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
connectionId,
dbName,
displayData.length,
displayOutputColumnNames,
exportQueryResultRows,
hasFilteredExportSql,
objectType,

View File

@@ -208,6 +208,8 @@ export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:str
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ImportDataWithProgressOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ImportFileOptions):Promise<connection.QueryResult>;
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;

View File

@@ -402,6 +402,10 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
}
export function ImportDataWithProgressOptions(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ImportDataWithProgressOptions'](arg1, arg2, arg3, arg4, arg5);
}
export function ImportLegacyConnections(arg1) {
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
}

View File

@@ -494,6 +494,7 @@ export namespace app {
}
export class ExportFileOptions {
format: string;
columns?: string[];
xlsxMaxRowsPerSheet?: number;
jobId?: string;
totalRowsHint?: number;
@@ -511,6 +512,7 @@ export namespace app {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.format = source["format"];
this.columns = source["columns"];
this.xlsxMaxRowsPerSheet = source["xlsxMaxRowsPerSheet"];
this.jobId = source["jobId"];
this.totalRowsHint = source["totalRowsHint"];
@@ -522,6 +524,20 @@ export namespace app {
this.insertSQLAllowEmptyTargetTable = source["insertSQLAllowEmptyTargetTable"];
}
}
export class ImportFileOptions {
columnMappings?: Record<string, string>;
jobId?: string;
static createFrom(source: any = {}) {
return new ImportFileOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.columnMappings = source["columnMappings"];
this.jobId = source["jobId"];
}
}
export class RedisExportKeysOptions {
scope?: string;
keys?: string[];