feat(export-workbench): 支持批量导出工作台并优化 SQL 导出性能

- 侧边栏批量表/批量库入口改为直接打开导出工作台,统一导出配置与进度视图
- 导出工作台新增 batch-tables / batch-databases 模式,支持连接、数据库、对象选择与独立历史记录键
- 连接、数据库、对象下拉项补齐完整名展示与悬浮提示,避免长名称被截断后不可识别
- 后端新增批量对象/批量库导出 WithOptions 链路,统一返回输出文件/目录与进度信息
- SQL dump 数据导出改为按方言批量写入,MySQL/PG 等使用多值 VALUES,Oracle/达梦使用 INSERT ALL
- 补充导出工作台与 SQL dump 的回归测试和 benchmark,覆盖批量模式与批量写入语义
This commit is contained in:
Syngnat
2026-06-17 16:50:05 +08:00
parent 954d126a8f
commit 4e31d47936
10 changed files with 1541 additions and 169 deletions

View File

@@ -89,7 +89,11 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../util
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { buildTableExportTab } from '../utils/tableExportTab';
import {
buildBatchDatabaseExportWorkbenchTab,
buildBatchTableExportWorkbenchTab,
buildTableExportTab,
} from '../utils/tableExportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
@@ -4024,6 +4028,30 @@ const Sidebar: React.FC<{
setIsBatchModalOpen(true);
};
const openBatchTableExportWorkbench = () => {
let connId = '';
let dbName = '';
if (selectedNodesRef.current.length > 0) {
const node = selectedNodesRef.current[0];
if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') {
connId = node.key as string;
} else if (node.type === 'database') {
connId = node.dataRef.id;
dbName = node.title;
} else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
connId = node.dataRef.id;
dbName = node.dataRef.dbName;
}
}
addTab(buildBatchTableExportWorkbenchTab({
connectionId: connId,
dbName: dbName || undefined,
title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象',
}));
};
const loadDatabasesForBatch = async (conn: SavedConnection) => {
const config = {
...conn.config,
@@ -4347,6 +4375,24 @@ const Sidebar: React.FC<{
setIsBatchDbModalOpen(true);
};
const openBatchDatabaseExportWorkbench = () => {
let connId = '';
if (selectedNodesRef.current.length > 0) {
const node = selectedNodesRef.current[0];
if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') {
connId = node.key as string;
} else if (node.type === 'database' || node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
connId = node.dataRef.id;
}
}
addTab(buildBatchDatabaseExportWorkbenchTab({
connectionId: connId,
title: '批量导出库',
}));
};
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
setBatchConnContext(conn);
@@ -9221,7 +9267,7 @@ const Sidebar: React.FC<{
<button
type="button"
className="gn-v2-rail-tool gn-v2-rail-action"
onClick={() => openBatchOperationModal()}
onClick={() => openBatchTableExportWorkbench()}
aria-label={v2BatchTablesLabel}
data-sidebar-batch-table-action="true"
>
@@ -9232,7 +9278,7 @@ const Sidebar: React.FC<{
<button
type="button"
className="gn-v2-rail-tool gn-v2-rail-action"
onClick={() => openBatchDatabaseModal()}
onClick={() => openBatchDatabaseExportWorkbench()}
aria-label={v2BatchDatabasesLabel}
data-sidebar-batch-database-action="true"
>
@@ -9507,7 +9553,7 @@ const Sidebar: React.FC<{
icon={<TableOutlined />}
aria-label="批量操作表"
data-sidebar-batch-table-action="true"
onClick={() => openBatchOperationModal()}
onClick={() => openBatchTableExportWorkbench()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
@@ -9520,7 +9566,7 @@ const Sidebar: React.FC<{
icon={<DatabaseOutlined />}
aria-label="批量操作库"
data-sidebar-batch-database-action="true"
onClick={() => openBatchDatabaseModal()}
onClick={() => openBatchDatabaseExportWorkbench()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>

View File

@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench';
import type { ExportProgressState } from './useExportProgressRunner';
const mockUpsertTableExportHistory = vi.fn();
const createMockStoreState = () => ({
@@ -24,7 +25,7 @@ const createMockStoreState = () => ({
tableExportHistories: {},
upsertTableExportHistory: mockUpsertTableExportHistory,
});
const createMockProgressRunnerState = () => ({
const createMockProgressRunnerState = (): ExportProgressState => ({
open: true,
jobId: 'job-1',
title: '导出 SYS.test',
@@ -40,17 +41,27 @@ const createMockProgressRunnerState = () => ({
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
message: '',
});
const createProgressRunnerState = (
overrides: Partial<ExportProgressState> = {},
): ExportProgressState => ({
...createMockProgressRunnerState(),
...overrides,
});
let mockStoreState = createMockStoreState();
let mockProgressRunnerState = createMockProgressRunnerState();
let mockProgressRunnerState: ExportProgressState = createMockProgressRunnerState();
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector(mockStoreState),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
DBGetDatabases: vi.fn(),
DBGetTables: vi.fn(),
ExportDatabasesSQLWithOptions: vi.fn(),
ExportQueryWithOptions: vi.fn(),
ExportTableWithOptions: vi.fn(),
ExportTablesSQLWithOptions: vi.fn(),
}));
vi.mock('./useExportProgressRunner', () => ({
@@ -167,6 +178,99 @@ describe('TableExportWorkbench', () => {
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx');
});
it('renders batch table workbench copy and object progress summary', () => {
mockProgressRunnerState = createProgressRunnerState({
title: '结构 · SYS',
targetName: 'SYS · 8 个对象',
format: 'SQL',
current: 3,
total: 8,
totalRowsKnown: true,
filePath: '/Users/yangguofeng/Desktop/SYS_schema_8tables.sql',
stage: '正在导出 orders (4/8)',
});
const markup = renderToStaticMarkup(
<TableExportWorkbench
tab={{
id: 'table-export-batch-tables-conn-1-SYS',
title: '批量导出对象',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'SYS',
exportWorkbenchMode: 'batch-tables',
}}
/>,
);
expect(markup).toContain('模式 · 批量对象');
expect(markup).toContain('导出内容');
expect(markup).toContain('批量对象导出会统一生成一个 SQL 文件');
expect(markup).toContain('已完成 3 / 8 个对象');
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS_schema_8tables.sql');
});
it('renders batch database history with directory-oriented labels', () => {
mockProgressRunnerState = createProgressRunnerState({
open: false,
jobId: '',
title: '',
targetName: '',
format: '',
startedAt: 0,
finishedAt: 0,
status: 'idle',
stage: '',
current: 0,
total: 0,
totalRowsKnown: false,
filePath: '',
message: '',
});
mockStoreState = {
...createMockStoreState(),
tableExportHistories: {
'conn-1::__batch_databases__': [
{
jobId: 'job-batch-db-1',
targetName: '3 个数据库',
startedAt: 1_000,
finishedAt: 31_000,
format: 'SQL',
scope: 'selectedDatabases',
scopeLabel: '已选数据库3',
strategyLabel: '批量库 SQL 导出 · 导出库结构',
status: 'done',
stage: '导出完成',
current: 3,
total: 3,
totalRowsKnown: true,
filePath: '/Users/yangguofeng/Desktop/export-batch-dbs',
message: '',
},
],
},
};
const markup = renderToStaticMarkup(
<TableExportWorkbench
tab={{
id: 'table-export-batch-databases-conn-1',
title: '批量导出库',
type: 'table-export',
connectionId: 'conn-1',
exportWorkbenchMode: 'batch-databases',
}}
/>,
);
expect(markup).toContain('模式 · 批量库');
expect(markup).toContain('将在开始导出时先选择输出目录');
expect(markup).toContain('已完成 3 / 3 个库');
expect(markup).toContain('/Users/yangguofeng/Desktop/export-batch-dbs');
expect(markup).toContain('目录');
});
it('keeps only one progress component in source and no longer uses top tabs', () => {
const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8');
const progressMatches = source.match(/<ExportProgressBar\b/g) || [];

File diff suppressed because it is too large Load Diff

View File

@@ -472,6 +472,7 @@ export interface TabData {
sidebarLocateKey?: string; // Precise sidebar tree key for locating an object node
savedQueryId?: string; // Saved query identity for quick-save behavior
objectType?: 'table' | 'view' | 'materialized-view'; // Table-like object type for shared viewers
exportWorkbenchMode?: 'single' | 'batch-tables' | 'batch-databases';
tableExportScopeOptions?: TableExportScopeOption[];
tableExportInitialScope?: TableExportScope;
tableExportQueryByScope?: Partial<Record<TableExportScope, string>>;

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
buildBatchDatabaseExportWorkbenchTab,
buildBatchTableExportWorkbenchTab,
buildExportWorkbenchHistoryKey,
buildTableExportHistoryKey,
buildTableExportTab,
DEFAULT_TABLE_EXPORT_SCOPE_OPTION,
@@ -11,6 +14,20 @@ describe('tableExportTab', () => {
expect(buildTableExportHistoryKey(' conn-1 ', ' app ', ' public.orders ')).toBe('conn-1::app::public.orders');
});
it('builds batch workbench history keys by mode', () => {
expect(buildExportWorkbenchHistoryKey({
connectionId: ' conn-1 ',
dbName: ' app ',
tableName: 'orders',
exportWorkbenchMode: 'batch-tables',
})).toBe('conn-1::app::__batch_tables__');
expect(buildExportWorkbenchHistoryKey({
connectionId: ' conn-1 ',
dbName: ' ignored ',
exportWorkbenchMode: 'batch-databases',
})).toBe('conn-1::__batch_databases__');
});
it('builds a stable table export tab with normalized defaults', () => {
const tab = buildTableExportTab({
connectionId: 'conn-1',
@@ -21,6 +38,7 @@ describe('tableExportTab', () => {
expect(tab.id).toBe('table-export-conn-1-app-public.orders');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('导出 public.orders');
expect(tab.exportWorkbenchMode).toBe('single');
expect(tab.tableExportScopeOptions).toEqual([DEFAULT_TABLE_EXPORT_SCOPE_OPTION]);
expect(tab.tableExportInitialScope).toBe('all');
expect(tab.tableExportQueryByScope).toBeUndefined();
@@ -60,4 +78,28 @@ describe('tableExportTab', () => {
filteredAll: 42,
});
});
it('builds batch table export workbench tabs with stable ids', () => {
const tab = buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'SYS',
});
expect(tab.id).toBe('table-export-batch-tables-conn-1-SYS');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('批量导出对象');
expect(tab.exportWorkbenchMode).toBe('batch-tables');
expect(tab.dbName).toBe('SYS');
});
it('builds batch database export workbench tabs with stable ids', () => {
const tab = buildBatchDatabaseExportWorkbenchTab({
connectionId: 'conn-1',
});
expect(tab.id).toBe('table-export-batch-databases-conn-1');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('批量导出库');
expect(tab.exportWorkbenchMode).toBe('batch-databases');
});
});

View File

@@ -18,6 +18,21 @@ export const buildTableExportHistoryKey = (
].join('::');
};
export const buildExportWorkbenchHistoryKey = (
input: Pick<TabData, 'connectionId' | 'dbName' | 'tableName' | 'exportWorkbenchMode'>,
): string => {
const mode = input.exportWorkbenchMode || 'single';
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
if (mode === 'batch-tables') {
return [connectionId, dbName, '__batch_tables__'].join('::');
}
if (mode === 'batch-databases') {
return [connectionId, '__batch_databases__'].join('::');
}
return buildTableExportHistoryKey(connectionId, dbName, input.tableName);
};
type BuildTableExportTabInput = {
connectionId: string;
dbName?: string;
@@ -32,6 +47,17 @@ type BuildTableExportTabInput = {
rowCountByScope?: Partial<Record<TableExportScope, number>>;
};
type BuildBatchTableExportWorkbenchTabInput = {
connectionId: string;
dbName?: string;
title?: string;
};
type BuildBatchDatabaseExportWorkbenchTabInput = {
connectionId: string;
title?: string;
};
const normalizeScopeOptions = (
scopeOptions: TableExportScopeOption[] | undefined,
): TableExportScopeOption[] => {
@@ -108,6 +134,7 @@ export const buildTableExportTab = (input: BuildTableExportTabInput): TabData =>
id: `table-export-${connectionId}-${dbName}-${tableName}`,
title: String(input.title || `导出 ${objectLabel}`).trim() || `导出 ${objectLabel}`,
type: 'table-export',
exportWorkbenchMode: 'single',
connectionId,
dbName,
tableName,
@@ -121,3 +148,34 @@ export const buildTableExportTab = (input: BuildTableExportTabInput): TabData =>
tableExportRowCountByScope: normalizeRowCountByScope(input.rowCountByScope),
};
};
export const buildBatchTableExportWorkbenchTab = (
input: BuildBatchTableExportWorkbenchTabInput,
): TabData => {
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
const scopeSuffix = dbName || 'all';
return {
id: `table-export-batch-tables-${connectionId || 'none'}-${scopeSuffix}`,
title: String(input.title || '批量导出对象').trim() || '批量导出对象',
type: 'table-export',
exportWorkbenchMode: 'batch-tables',
connectionId,
dbName: dbName || undefined,
initialTab: 'config',
};
};
export const buildBatchDatabaseExportWorkbenchTab = (
input: BuildBatchDatabaseExportWorkbenchTabInput,
): TabData => {
const connectionId = String(input.connectionId || '').trim();
return {
id: `table-export-batch-databases-${connectionId || 'none'}`,
title: String(input.title || '批量导出库').trim() || '批量导出库',
type: 'table-export',
exportWorkbenchMode: 'batch-databases',
connectionId,
initialTab: 'config',
};
};

View File

@@ -110,6 +110,8 @@ export function ExportDataWithOptions(arg1:Array<Record<string, any>>,arg2:Array
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
export function ExportDatabasesSQLWithOptions(arg1:connection.ConnectionConfig,arg2:Array<string>,arg3:boolean,arg4:app.ExportFileOptions):Promise<connection.QueryResult>;
export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:string):Promise<connection.QueryResult>;
export function ExportQueryWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ExportFileOptions):Promise<connection.QueryResult>;
@@ -126,6 +128,8 @@ export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
export function ExportTablesSQLWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean,arg5:boolean,arg6:app.ExportFileOptions):Promise<connection.QueryResult>;
export function GenerateQueryID():Promise<string>;
export function GetAppInfo():Promise<connection.QueryResult>;

View File

@@ -210,6 +210,10 @@ export function ExportDatabaseSQL(arg1, arg2, arg3) {
return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3);
}
export function ExportDatabasesSQLWithOptions(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportDatabasesSQLWithOptions'](arg1, arg2, arg3, arg4);
}
export function ExportQuery(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ExportQuery'](arg1, arg2, arg3, arg4, arg5);
}
@@ -242,6 +246,10 @@ export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
}
export function ExportTablesSQLWithOptions(arg1, arg2, arg3, arg4, arg5, arg6) {
return window['go']['app']['App']['ExportTablesSQLWithOptions'](arg1, arg2, arg3, arg4, arg5, arg6);
}
export function GenerateQueryID() {
return window['go']['app']['App']['GenerateQueryID']();
}