mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(data-sync/export): 支持同步与备份后台工作台
- 将数据同步及各级 SQL 备份统一迁入稳定工作区 - 全局保留任务状态、进度和结构化日志,关闭后可重新接回 - 支持从当前任务及历史记录打开 SQL 恢复工作台 - 锁定运行中配置并禁止后台任务分离到独立窗口
This commit is contained in:
38
frontend/src/utils/dataSyncTab.test.ts
Normal file
38
frontend/src/utils/dataSyncTab.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { setCurrentLanguage, t } from '../i18n';
|
||||
import { getTabDisplayKindLabel } from './tabDisplay';
|
||||
import {
|
||||
buildDataSyncWorkbenchTab,
|
||||
resolveDataSyncWorkbenchTabId,
|
||||
} from './dataSyncTab';
|
||||
|
||||
describe('dataSyncTab', () => {
|
||||
afterEach(() => {
|
||||
setCurrentLanguage('zh-CN');
|
||||
});
|
||||
|
||||
it('builds one stable workbench tab for each data workflow entry', () => {
|
||||
expect(resolveDataSyncWorkbenchTabId('sync')).toBe('data-sync-workbench-sync');
|
||||
expect(resolveDataSyncWorkbenchTabId('schemaCompare')).toBe('data-sync-workbench-schema-compare');
|
||||
expect(resolveDataSyncWorkbenchTabId('dataCompare')).toBe('data-sync-workbench-data-compare');
|
||||
|
||||
expect(buildDataSyncWorkbenchTab({ entryMode: 'schemaCompare' })).toMatchObject({
|
||||
id: 'data-sync-workbench-schema-compare',
|
||||
title: t('data_sync.entry_mode.schema_compare.title'),
|
||||
type: 'data-sync',
|
||||
connectionId: '',
|
||||
dataSyncEntryMode: 'schemaCompare',
|
||||
});
|
||||
expect(getTabDisplayKindLabel(buildDataSyncWorkbenchTab({ entryMode: 'sync' }))).toBe('SYNC');
|
||||
});
|
||||
|
||||
it('localizes default titles without changing stable tab ids', () => {
|
||||
setCurrentLanguage('en-US');
|
||||
|
||||
const tab = buildDataSyncWorkbenchTab({ entryMode: 'dataCompare' });
|
||||
|
||||
expect(tab.id).toBe('data-sync-workbench-data-compare');
|
||||
expect(tab.title).toBe(t('data_sync.entry_mode.data_compare.title'));
|
||||
});
|
||||
});
|
||||
36
frontend/src/utils/dataSyncTab.ts
Normal file
36
frontend/src/utils/dataSyncTab.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { TabData } from '../types';
|
||||
import { t } from '../i18n';
|
||||
import {
|
||||
resolveDataSyncEntryModePresentation,
|
||||
type DataSyncEntryMode,
|
||||
} from '../components/dataSyncEntryMode';
|
||||
|
||||
type BuildDataSyncWorkbenchTabInput = {
|
||||
entryMode: DataSyncEntryMode;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const DATA_SYNC_ENTRY_MODE_SLUGS: Record<DataSyncEntryMode, string> = {
|
||||
sync: 'sync',
|
||||
schemaCompare: 'schema-compare',
|
||||
dataCompare: 'data-compare',
|
||||
};
|
||||
|
||||
export const resolveDataSyncWorkbenchTabId = (
|
||||
entryMode: DataSyncEntryMode,
|
||||
): string => `data-sync-workbench-${DATA_SYNC_ENTRY_MODE_SLUGS[entryMode]}`;
|
||||
|
||||
export const buildDataSyncWorkbenchTab = (
|
||||
input: BuildDataSyncWorkbenchTabInput,
|
||||
): TabData => {
|
||||
const presentation = resolveDataSyncEntryModePresentation(input.entryMode, t);
|
||||
const title = String(input.title || presentation.title).trim() || presentation.title;
|
||||
|
||||
return {
|
||||
id: resolveDataSyncWorkbenchTabId(input.entryMode),
|
||||
title,
|
||||
type: 'data-sync',
|
||||
connectionId: '',
|
||||
dataSyncEntryMode: input.entryMode,
|
||||
};
|
||||
};
|
||||
@@ -33,4 +33,17 @@ describe('sqlFileExecutionTab', () => {
|
||||
sqlFileExecutionRequestKey: 'job-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens completed backup artifacts without automatically executing them', () => {
|
||||
const tab = buildSQLFileExecutionWorkbenchTab({
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
filePath: '/tmp/demo_backup.sql',
|
||||
fileName: 'demo_backup.sql',
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
expect(tab.sqlFileExecutionRequestKey).toBeUndefined();
|
||||
expect(tab.filePath).toBe('/tmp/demo_backup.sql');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type BuildSQLFileExecutionWorkbenchTabInput = {
|
||||
fileName?: string;
|
||||
fileSizeMB?: string;
|
||||
requestKey?: string;
|
||||
autoStart?: boolean;
|
||||
};
|
||||
|
||||
export const buildSQLFileExecutionWorkbenchTab = (
|
||||
@@ -33,6 +34,10 @@ export const buildSQLFileExecutionWorkbenchTab = (
|
||||
const fileName = String(input.fileName || '').trim();
|
||||
const defaultTitle = fileName || t('sidebar.sql_file_exec.title');
|
||||
|
||||
const requestKey = input.autoStart === false
|
||||
? ''
|
||||
: String(input.requestKey || `sql-file-execution-${Date.now()}`).trim();
|
||||
|
||||
return {
|
||||
id: resolveSQLFileExecutionWorkbenchTabId(connectionId, dbName || undefined, filePath),
|
||||
title: defaultTitle,
|
||||
@@ -41,6 +46,6 @@ export const buildSQLFileExecutionWorkbenchTab = (
|
||||
...(dbName ? { dbName } : {}),
|
||||
filePath,
|
||||
sqlFileExecutionFileSizeMB: String(input.fileSizeMB || '').trim() || undefined,
|
||||
sqlFileExecutionRequestKey: String(input.requestKey || `sql-file-execution-${Date.now()}`).trim(),
|
||||
...(requestKey ? { sqlFileExecutionRequestKey: requestKey } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -478,6 +478,7 @@ export const getTabDisplayKindLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'design') return 'DESIGN';
|
||||
if (tab.type === 'table-overview') return 'DB';
|
||||
if (tab.type === 'table-export') return 'EXPORT';
|
||||
if (tab.type === 'data-sync') return 'SYNC';
|
||||
if (tab.type === 'sql-analysis') return 'ANALYZE';
|
||||
if (tab.type === 'sql-audit') return 'AUDIT';
|
||||
if (tab.type.startsWith('redis')) return 'REDIS';
|
||||
|
||||
@@ -3,7 +3,9 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBatchDatabaseExportWorkbenchTab,
|
||||
buildBatchTableExportWorkbenchTab,
|
||||
buildDatabaseExportWorkbenchTab,
|
||||
buildExportWorkbenchHistoryKey,
|
||||
buildSchemaExportWorkbenchTab,
|
||||
buildTableExportHistoryKey,
|
||||
buildTableExportTab,
|
||||
DEFAULT_TABLE_EXPORT_SCOPE_OPTION,
|
||||
@@ -31,6 +33,17 @@ describe('tableExportTab', () => {
|
||||
dbName: ' ignored ',
|
||||
exportWorkbenchMode: 'batch-databases',
|
||||
})).toBe('conn-1::__batch_databases__');
|
||||
expect(buildExportWorkbenchHistoryKey({
|
||||
connectionId: ' conn-1 ',
|
||||
dbName: ' app ',
|
||||
exportWorkbenchMode: 'database',
|
||||
})).toBe('conn-1::app::__database__');
|
||||
expect(buildExportWorkbenchHistoryKey({
|
||||
connectionId: ' conn-1 ',
|
||||
dbName: ' app ',
|
||||
schemaName: ' sales ',
|
||||
exportWorkbenchMode: 'schema',
|
||||
})).toBe('conn-1::app::sales::__schema__');
|
||||
});
|
||||
|
||||
it('builds a stable table export tab with normalized defaults', () => {
|
||||
@@ -99,6 +112,24 @@ describe('tableExportTab', () => {
|
||||
expect(tab.dbName).toBe('SYS');
|
||||
});
|
||||
|
||||
it('carries selected objects and an auto-start request into the batch table workbench', () => {
|
||||
const tab = buildBatchTableExportWorkbenchTab({
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'SYS',
|
||||
initialObjectNames: [' users ', 'orders', 'users'],
|
||||
contentMode: 'backup',
|
||||
includeDropIfExists: true,
|
||||
requestKey: 'batch-tables-1',
|
||||
});
|
||||
|
||||
expect(tab).toEqual(expect.objectContaining({
|
||||
tableExportInitialObjectNames: ['users', 'orders'],
|
||||
tableExportContentMode: 'backup',
|
||||
tableExportIncludeDropIfExists: true,
|
||||
tableExportRequestKey: 'batch-tables-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('builds batch database export workbench tabs with stable ids', () => {
|
||||
setCurrentLanguage('zh-CN');
|
||||
const tab = buildBatchDatabaseExportWorkbenchTab({
|
||||
@@ -111,6 +142,57 @@ describe('tableExportTab', () => {
|
||||
expect(tab.exportWorkbenchMode).toBe('batch-databases');
|
||||
});
|
||||
|
||||
it('carries selected databases and an auto-start request into the batch database workbench', () => {
|
||||
const tab = buildBatchDatabaseExportWorkbenchTab({
|
||||
connectionId: 'conn-1',
|
||||
initialDatabaseNames: [' app ', 'audit', 'app'],
|
||||
contentMode: 'schema',
|
||||
includeDropIfExists: true,
|
||||
requestKey: 'batch-databases-1',
|
||||
});
|
||||
|
||||
expect(tab).toEqual(expect.objectContaining({
|
||||
tableExportInitialDatabaseNames: ['app', 'audit'],
|
||||
tableExportContentMode: 'schema',
|
||||
tableExportIncludeDropIfExists: true,
|
||||
tableExportRequestKey: 'batch-databases-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('builds direct database and schema workbenches with stable targets', () => {
|
||||
const databaseTab = buildDatabaseExportWorkbenchTab({
|
||||
connectionId: 'conn-1',
|
||||
dbName: ' app ',
|
||||
contentMode: 'backup',
|
||||
requestKey: 'database-1',
|
||||
});
|
||||
const schemaTab = buildSchemaExportWorkbenchTab({
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'app',
|
||||
schemaName: ' sales ',
|
||||
contentMode: 'schema',
|
||||
requestKey: 'schema-1',
|
||||
});
|
||||
|
||||
expect(databaseTab).toEqual(expect.objectContaining({
|
||||
id: 'table-export-database-conn-1-app',
|
||||
type: 'table-export',
|
||||
exportWorkbenchMode: 'database',
|
||||
dbName: 'app',
|
||||
tableExportContentMode: 'backup',
|
||||
tableExportRequestKey: 'database-1',
|
||||
}));
|
||||
expect(schemaTab).toEqual(expect.objectContaining({
|
||||
id: 'table-export-schema-conn-1-app-sales',
|
||||
type: 'table-export',
|
||||
exportWorkbenchMode: 'schema',
|
||||
dbName: 'app',
|
||||
schemaName: 'sales',
|
||||
tableExportContentMode: 'schema',
|
||||
tableExportRequestKey: 'schema-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses the current language for batch workbench fallback titles', () => {
|
||||
setCurrentLanguage('en-US');
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TabData, TableExportScope, TableExportScopeOption } from '../types';
|
||||
import type { TabData, TableExportContentMode, TableExportScope, TableExportScopeOption } from '../types';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export const DEFAULT_TABLE_EXPORT_SCOPE_OPTION: TableExportScopeOption = {
|
||||
@@ -24,7 +24,7 @@ export const buildTableExportHistoryKey = (
|
||||
};
|
||||
|
||||
export const buildExportWorkbenchHistoryKey = (
|
||||
input: Pick<TabData, 'connectionId' | 'dbName' | 'tableName' | 'exportWorkbenchMode'>,
|
||||
input: Pick<TabData, 'connectionId' | 'dbName' | 'tableName' | 'schemaName' | 'exportWorkbenchMode'>,
|
||||
): string => {
|
||||
const mode = input.exportWorkbenchMode || 'single';
|
||||
const connectionId = String(input.connectionId || '').trim();
|
||||
@@ -35,9 +35,21 @@ export const buildExportWorkbenchHistoryKey = (
|
||||
if (mode === 'batch-databases') {
|
||||
return [connectionId, '__batch_databases__'].join('::');
|
||||
}
|
||||
if (mode === 'database') {
|
||||
return [connectionId, dbName, '__database__'].join('::');
|
||||
}
|
||||
if (mode === 'schema') {
|
||||
return [connectionId, dbName, String(input.schemaName || '').trim(), '__schema__'].join('::');
|
||||
}
|
||||
return buildTableExportHistoryKey(connectionId, dbName, input.tableName);
|
||||
};
|
||||
|
||||
type ExportWorkbenchLaunchOptions = {
|
||||
contentMode?: TableExportContentMode;
|
||||
includeDropIfExists?: boolean;
|
||||
requestKey?: string;
|
||||
};
|
||||
|
||||
type BuildTableExportTabInput = {
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
@@ -52,17 +64,51 @@ type BuildTableExportTabInput = {
|
||||
rowCountByScope?: Partial<Record<TableExportScope, number>>;
|
||||
};
|
||||
|
||||
type BuildBatchTableExportWorkbenchTabInput = {
|
||||
type BuildBatchTableExportWorkbenchTabInput = ExportWorkbenchLaunchOptions & {
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
title?: string;
|
||||
initialObjectNames?: string[];
|
||||
};
|
||||
|
||||
type BuildBatchDatabaseExportWorkbenchTabInput = {
|
||||
type BuildBatchDatabaseExportWorkbenchTabInput = ExportWorkbenchLaunchOptions & {
|
||||
connectionId: string;
|
||||
title?: string;
|
||||
initialDatabaseNames?: string[];
|
||||
};
|
||||
|
||||
type BuildDatabaseExportWorkbenchTabInput = ExportWorkbenchLaunchOptions & {
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type BuildSchemaExportWorkbenchTabInput = ExportWorkbenchLaunchOptions & {
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
schemaName: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const normalizeNameList = (values: string[] | undefined): string[] | undefined => {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
const seen = new Set<string>();
|
||||
const result = values
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter((value) => {
|
||||
if (!value || seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
return result.length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const buildLaunchMetadata = (input: ExportWorkbenchLaunchOptions): Partial<TabData> => ({
|
||||
...(input.contentMode ? { tableExportContentMode: input.contentMode } : {}),
|
||||
...(input.includeDropIfExists === true ? { tableExportIncludeDropIfExists: true } : {}),
|
||||
...(String(input.requestKey || '').trim() ? { tableExportRequestKey: String(input.requestKey).trim() } : {}),
|
||||
});
|
||||
|
||||
const normalizeScopeOptions = (
|
||||
scopeOptions: TableExportScopeOption[] | undefined,
|
||||
): TableExportScopeOption[] => {
|
||||
@@ -169,6 +215,10 @@ export const buildBatchTableExportWorkbenchTab = (
|
||||
connectionId,
|
||||
dbName: dbName || undefined,
|
||||
initialTab: 'config',
|
||||
...(normalizeNameList(input.initialObjectNames)
|
||||
? { tableExportInitialObjectNames: normalizeNameList(input.initialObjectNames) }
|
||||
: {}),
|
||||
...buildLaunchMetadata(input),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -183,5 +233,49 @@ export const buildBatchDatabaseExportWorkbenchTab = (
|
||||
exportWorkbenchMode: 'batch-databases',
|
||||
connectionId,
|
||||
initialTab: 'config',
|
||||
...(normalizeNameList(input.initialDatabaseNames)
|
||||
? { tableExportInitialDatabaseNames: normalizeNameList(input.initialDatabaseNames) }
|
||||
: {}),
|
||||
...buildLaunchMetadata(input),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildDatabaseExportWorkbenchTab = (
|
||||
input: BuildDatabaseExportWorkbenchTabInput,
|
||||
): TabData => {
|
||||
const connectionId = String(input.connectionId || '').trim();
|
||||
const dbName = String(input.dbName || '').trim();
|
||||
const targetName = dbName || t('data_export.progress.value.target_fallback');
|
||||
return {
|
||||
id: `table-export-database-${connectionId || 'none'}-${dbName || 'default'}`,
|
||||
title: String(input.title || t('data_export.workbench.task.export_target', { name: targetName })).trim()
|
||||
|| t('data_export.workbench.task.export_target', { name: targetName }),
|
||||
type: 'table-export',
|
||||
exportWorkbenchMode: 'database',
|
||||
connectionId,
|
||||
dbName: dbName || undefined,
|
||||
initialTab: 'config',
|
||||
...buildLaunchMetadata(input),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSchemaExportWorkbenchTab = (
|
||||
input: BuildSchemaExportWorkbenchTabInput,
|
||||
): TabData => {
|
||||
const connectionId = String(input.connectionId || '').trim();
|
||||
const dbName = String(input.dbName || '').trim();
|
||||
const schemaName = String(input.schemaName || '').trim();
|
||||
const targetName = [dbName, schemaName].filter(Boolean).join('.') || t('data_export.progress.value.target_fallback');
|
||||
return {
|
||||
id: `table-export-schema-${connectionId || 'none'}-${dbName || 'default'}-${schemaName || 'schema'}`,
|
||||
title: String(input.title || t('data_export.workbench.task.export_target', { name: targetName })).trim()
|
||||
|| t('data_export.workbench.task.export_target', { name: targetName }),
|
||||
type: 'table-export',
|
||||
exportWorkbenchMode: 'schema',
|
||||
connectionId,
|
||||
dbName: dbName || undefined,
|
||||
schemaName: schemaName || undefined,
|
||||
initialTab: 'config',
|
||||
...buildLaunchMetadata(input),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user