Files
MyGoNavi/frontend/src/components/DataSyncModal.tsx
tianqijiuyun-latiao 9364c48ef0 feat(i18n): 完善多模块多语言适配与发版验证
扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。
2026-06-17 13:17:33 +08:00

1638 lines
83 KiB
TypeScript

import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd';
import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
import { t } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode } from './dataSyncEntryMode';
const { Title, Text } = Typography;
const { Step } = Steps;
const { Option } = Select;
const { TextArea } = Input;
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
type SyncLogItem = { level: string; message: string; ts?: number };
type TableDiffSummary = {
table: string;
pkColumn?: string;
canSync?: boolean;
inserts?: number;
updates?: number;
deletes?: number;
same?: number;
schemaDiffCount?: number;
message?: string;
targetTableExists?: boolean;
plannedAction?: string;
warnings?: string[];
unsupportedObjects?: string[];
indexesToCreate?: number;
indexesSkipped?: number;
};
type TableOps = {
insert: boolean;
update: boolean;
delete: boolean;
selectedInsertPks?: string[];
selectedUpdatePks?: string[];
selectedDeletePks?: string[];
};
type WorkflowType = 'sync' | 'migration';
const quoteSqlIdent = (dbType: string, ident: string): string => {
return quoteIdentPart(dbType, String(ident || '').trim());
};
const quoteSqlTable = (dbType: string, tableName: string): string => {
return quoteQualifiedIdent(dbType, String(tableName || '').trim());
};
const toSqlLiteral = (value: any, dbType: string): string => {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'boolean') {
const t = String(dbType || '').toLowerCase();
if (t === 'sqlserver') return value ? '1' : '0';
return value ? 'TRUE' : 'FALSE';
}
if (value instanceof Date) {
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
try {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
} catch {
return `'${String(value).replace(/'/g, "''")}'`;
}
}
return `'${String(value).replace(/'/g, "''")}'`;
};
const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => {
if (typeof value === 'string') {
const normalized = normalizeTemporalLiteralText(value, columnType, false);
return toSqlLiteral(normalized, dbType);
}
if (value instanceof Date) {
const normalized = String(columnType || '').trim()
? formatLocalDateTimeLiteral(value)
: value.toISOString();
return toSqlLiteral(normalized, dbType);
}
return toSqlLiteral(value, dbType);
};
const resolveRedisDbIndex = (raw?: string): number => {
const value = Number(String(raw || '').trim());
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
};
const isServiceNameBackedSyncConnection = (conn?: SavedConnection): boolean => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'oracle') return true;
if (type !== 'oceanbase') return false;
const explicitProtocol = String((conn?.config as any)?.oceanBaseProtocol || '').trim().toLowerCase();
if (explicitProtocol === 'oracle') return true;
const params = new URLSearchParams(String(conn?.config?.connectionParams || ''));
const protocol = String(params.get('protocol') || params.get('tenantMode') || '').trim().toLowerCase();
return protocol === 'oracle';
};
const buildSqlPreview = (
previewData: any,
tableName: string,
dbType: string,
ops?: TableOps,
): { sqlText: string; statementCount: number } => {
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
const tableExpr = quoteSqlTable(dbType, tableName);
const pkCol = String(previewData.pkColumn || 'id');
const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object'
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const schemaStatements = Array.isArray(previewData.schemaStatements)
? previewData.schemaStatements
.map((item: any) => String(item || '').trim())
.filter((item: string) => item.length > 0)
: [];
schemaStatements.forEach((statement: string) => {
statements.push(statement.endsWith(';') ? statement : `${statement};`);
});
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : [];
const selectedInsert = new Set((ops?.selectedInsertPks || []).map((v) => String(v)));
const selectedUpdate = new Set((ops?.selectedUpdatePks || []).map((v) => String(v)));
const selectedDelete = new Set((ops?.selectedDeletePks || []).map((v) => String(v)));
if (ops?.insert !== false) {
insertRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return;
const row = rowWrap?.row || {};
const columns = Object.keys(row);
if (columns.length === 0) return;
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', ');
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
});
}
if (ops?.update !== false) {
updateRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return;
const source = rowWrap?.source || {};
const changedColumns = Array.isArray(rowWrap?.changedColumns)
? rowWrap.changedColumns
: Object.keys(source).filter((k) => k !== pkCol);
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
if (setCols.length === 0) return;
const setExpr = setCols
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`)
.join(', ');
statements.push(
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
if (ops?.delete) {
deleteRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
statements.push(
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
return {
sqlText: statements.join('\n'),
statementCount: statements.length,
};
};
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => {
const i18n = useOptionalI18n();
const i18nLanguage = i18n?.language;
const tr = (key: string, params?: Parameters<typeof t>[1]) => t(key, params, i18nLanguage);
const entryPresentation = resolveDataSyncEntryModePresentation(entryMode, tr);
const isSchemaCompareEntry = entryMode === 'schemaCompare';
const isDataCompareEntry = entryMode === 'dataCompare';
const isCompareEntry = entryPresentation.readOnly;
const connections = useStore((state) => state.connections);
const themeMode = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const { token } = antdTheme.useToken();
const darkMode = themeMode === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
// Step 1: Config
const [sourceConnId, setSourceConnId] = useState<string>('');
const [targetConnId, setTargetConnId] = useState<string>('');
const [sourceDb, setSourceDb] = useState<string>('');
const [targetDb, setTargetDb] = useState<string>('');
const [sourceDbs, setSourceDbs] = useState<string[]>([]);
const [targetDbs, setTargetDbs] = useState<string[]>([]);
// Step 2: Tables
const [allTables, setAllTables] = useState<string[]>([]);
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
const [sourceQuery, setSourceQuery] = useState<string>('');
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>(isSchemaCompareEntry ? 'schema' : 'data');
const [syncMode, setSyncMode] = useState<string>('insert_update');
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
const [createIndexes, setCreateIndexes] = useState<boolean>(false);
const [mongoCollectionName, setMongoCollectionName] = useState<string>('');
const [showSameTables, setShowSameTables] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [diffTables, setDiffTables] = useState<TableDiffSummary[]>([]);
const [tableOptions, setTableOptions] = useState<Record<string, TableOps>>({});
const [previewOpen, setPreviewOpen] = useState(false);
const [previewTable, setPreviewTable] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
// Step 3: Result
const [syncResult, setSyncResult] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [syncLogs, setSyncLogs] = useState<SyncLogItem[]>([]);
const [syncProgress, setSyncProgress] = useState<{ percent: number; current: number; total: number; table: string; stage: string }>({
percent: 0,
current: 0,
total: 0,
table: '',
stage: ''
});
const jobIdRef = useRef<string>('');
const logBoxRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const normalizeConnConfig = (conn: SavedConnection, database?: string) => (
buildRpcConnectionConfig(conn.config, {
database: typeof database === 'string' && !isServiceNameBackedSyncConnection(conn) ? database : (conn.config.database || ''),
})
);
useEffect(() => {
if (!open) return;
const offLog = EventsOn('sync:log', (event: SyncLogEvent) => {
if (!event || event.jobId !== jobIdRef.current) return;
const msg = String(event.message || '').trim();
if (!msg) return;
setSyncLogs(prev => [...prev, { level: String(event.level || 'info'), message: msg, ts: event.ts }]);
});
const offProgress = EventsOn('sync:progress', (event: SyncProgressEvent) => {
if (!event || event.jobId !== jobIdRef.current) return;
setSyncProgress(prev => ({
percent: typeof event.percent === 'number' ? event.percent : prev.percent,
current: typeof event.current === 'number' ? event.current : prev.current,
total: typeof event.total === 'number' ? event.total : prev.total,
table: typeof event.table === 'string' ? event.table : prev.table,
stage: typeof event.stage === 'string' ? event.stage : prev.stage,
}));
});
return () => {
if (typeof offLog === 'function') offLog();
if (typeof offProgress === 'function') offProgress();
};
}, [open]);
useEffect(() => {
if (!logBoxRef.current) return;
if (!autoScrollRef.current) return;
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
}, [syncLogs]);
useEffect(() => {
if (open) {
setCurrentStep(0);
setSourceConnId('');
setTargetConnId('');
setSourceDb('');
setTargetDb('');
setAllTables([]);
setSelectedTables([]);
setSourceDatasetMode('table');
setSourceQuery('');
setWorkflowType('sync');
setSyncContent(isSchemaCompareEntry ? 'schema' : 'data');
setSyncMode('insert_update');
setAutoAddColumns(true);
setTargetTableStrategy('existing_only');
setCreateIndexes(false);
setShowSameTables(false);
setAnalyzing(false);
setDiffTables([]);
setTableOptions({});
setPreviewOpen(false);
setPreviewTable('');
setPreviewLoading(false);
setPreviewData(null);
setSyncResult(null);
setSyncing(false);
setSyncLogs([]);
setSyncProgress({ percent: 0, current: 0, total: 0, table: '', stage: '' });
jobIdRef.current = '';
autoScrollRef.current = true;
}
}, [open, isSchemaCompareEntry]);
useEffect(() => {
if (isSchemaCompareEntry) {
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (sourceDatasetMode !== 'table') {
setSourceDatasetMode('table');
}
if (syncContent !== 'schema') {
setSyncContent('schema');
}
if (syncMode !== 'insert_update') {
setSyncMode('insert_update');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
return;
}
if (isDataCompareEntry) {
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (syncMode !== 'insert_update') {
setSyncMode('insert_update');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
return;
}
if (workflowType === 'migration') {
if (syncMode === 'insert_update') {
setSyncMode('insert_only');
}
if (syncContent === 'schema') {
setSyncContent('both');
}
if (targetTableStrategy === 'existing_only') {
setTargetTableStrategy('smart');
}
if (!createIndexes) {
setCreateIndexes(true);
}
} else {
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
}
}, [isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes]);
useEffect(() => {
if (sourceDatasetMode !== 'query') return;
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
if (autoAddColumns) {
setAutoAddColumns(false);
}
if (selectedTables.length > 1) {
setSelectedTables(selectedTables.slice(0, 1));
}
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
const handleSourceConnChange = async (connId: string) => {
setSourceConnId(connId);
setSourceDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setSourceDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e: any) {
message.error(tr('data_sync.message.fetch_source_databases_failed_detail', { detail: e?.message || String(e) }));
}
setLoading(false);
}
};
const handleTargetConnChange = async (connId: string) => {
setTargetConnId(connId);
setTargetDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setTargetDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e: any) {
message.error(tr('data_sync.message.fetch_target_databases_failed_detail', { detail: e?.message || String(e) }));
}
setLoading(false);
}
};
const nextToTables = async () => {
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
if (!sourceDb) return message.error("Select source database");
if (!targetDb) return message.error("Select target database");
setLoading(true);
try {
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
const dbName = isSourceQueryMode ? targetDb : sourceDb;
const conn = connections.find(c => c.id === connId);
if (conn) {
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
const tables = tableRows
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setSelectedTables(prev => {
const existing = prev.filter((name) => tables.includes(name));
if (isSourceQueryMode) {
return existing.slice(0, 1);
}
return existing;
});
setCurrentStep(1);
} else {
message.error(res.message ? tr('data_sync.message.fetch_tables_failed_detail', { detail: res.message }) : tr('data_sync.message.fetch_tables_failed'));
}
}
} catch (e: any) {
message.error(tr('data_sync.message.fetch_tables_failed_detail', { detail: e?.message || String(e) }));
}
setLoading(false);
};
const updateTableOption = (table: string, key: keyof TableOps, value: any) => {
setTableOptions(prev => ({
...prev,
[table]: { ...(prev[table] || { insert: true, update: true, delete: false }), [key]: value }
}));
};
const analyzeDiff = async () => {
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) return message.error(tr(selectionError));
if (!sourceConnId || !targetConnId) return message.error(tr('data_sync.message.select_connections_first'));
if (!sourceDb || !targetDb) return message.error(tr('data_sync.message.select_databases_first'));
setLoading(true);
setAnalyzing(true);
setDiffTables([]);
setTableOptions({});
setSyncLogs([]);
const sConn = connections.find(c => c.id === sourceConnId)!;
const tConn = connections.find(c => c.id === targetConnId)!;
const jobId = `analyze-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
jobIdRef.current = jobId;
autoScrollRef.current = true;
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: tr('data_sync.progress.stage.analyzing_diff') });
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
sourceDatabase: sourceDb,
targetDatabase: targetDb,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
jobId,
});
try {
const res = await DataSyncAnalyze(config as any);
if (res.success) {
const tables = ((res.data as any)?.tables || []) as TableDiffSummary[];
setDiffTables(tables);
const init: Record<string, TableOps> = {};
tables.forEach(t => {
const can = !!t.canSync;
init[t.table] = {
insert: can,
update: can,
delete: false,
selectedInsertPks: [],
selectedUpdatePks: [],
selectedDeletePks: [],
};
});
setTableOptions(init);
message.success(tr('data_sync.message.analysis_complete'));
} else {
message.error(res.message ? tr('data_sync.message.analysis_failed_detail', { detail: res.message }) : tr('data_sync.message.analysis_failed'));
}
} catch (e: any) {
message.error(tr('data_sync.message.analysis_failed_detail', { detail: e?.message || String(e) }));
}
setLoading(false);
setAnalyzing(false);
};
const openPreview = async (table: string) => {
if (!table) return;
const sConn = connections.find(c => c.id === sourceConnId)!;
const tConn = connections.find(c => c.id === targetConnId)!;
setPreviewOpen(true);
setPreviewTable(table);
setPreviewLoading(true);
setPreviewData(null);
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
sourceDatabase: sourceDb,
targetDatabase: targetDb,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
});
try {
const res = await DataSyncPreview(config as any, table, 200);
if (res.success) {
setPreviewData(res.data);
} else {
message.error(res.message ? tr('data_sync.message.preview_load_failed_detail', { detail: res.message }) : tr('data_sync.message.preview_load_failed'));
}
} catch (e: any) {
message.error(tr('data_sync.message.preview_load_failed_detail', { detail: e?.message || String(e) }));
}
setPreviewLoading(false);
};
const runSync = async () => {
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) {
message.error(tr(selectionError));
return;
}
if (syncContent !== 'schema' && diffTables.length === 0) {
message.error(tr('data_sync.message.analyze_before_sync'));
return;
}
if (syncContent !== 'schema' && syncMode === 'full_overwrite') {
const ok = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: tr('data_sync.modal.full_overwrite_title'),
content: tr('data_sync.modal.full_overwrite_content'),
okText: tr('data_sync.modal.full_overwrite_ok'),
cancelText: tr('common.cancel'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!ok) return;
}
setLoading(true);
setSyncing(true);
setCurrentStep(2);
setSyncResult(null);
setSyncLogs([]);
const sConn = connections.find(c => c.id === sourceConnId)!;
const tConn = connections.find(c => c.id === targetConnId)!;
const jobId = `sync-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
jobIdRef.current = jobId;
autoScrollRef.current = true;
setSyncProgress({
percent: 0,
current: 0,
total: selectedTables.length,
table: '',
stage: tr('data_sync.progress.stage.preparing'),
});
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
sourceDatabase: sourceDb,
targetDatabase: targetDb,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
tableOptions,
jobId,
});
try {
const res = await DataSync(config as any);
setSyncResult(res);
if (Array.isArray(res?.logs) && res.logs.length > 0) {
setSyncLogs(prev => {
if (prev.length > 0) return prev;
return (res.logs as string[]).map((log) => {
const msg = String(log || '').trim();
if (msg.includes('致命错误') || msg.includes('失败')) return { level: 'error', message: msg }; // i18n-scan: allow-raw backend log severity markers
if (msg.includes('跳过') || msg.includes('警告')) return { level: 'warn', message: msg }; // i18n-scan: allow-raw backend log severity markers
return { level: 'info', message: msg };
});
});
}
} catch (e: any) {
message.error(tr('data_sync.message.sync_execution_failed_detail', { detail: e?.message || String(e) }));
setSyncResult({ success: false, message: tr('data_sync.message.sync_execution_failed'), logs: [] });
}
setLoading(false);
setSyncing(false);
};
const renderSyncLogItem = (item: SyncLogItem) => {
const level = String(item.level || 'info').toLowerCase();
const color = level === 'error' ? '#ff4d4f' : (level === 'warn' ? '#faad14' : '#595959');
const label = level === 'error' ? tr('data_sync.log.level.error') : (level === 'warn' ? tr('data_sync.log.level.warn') : tr('data_sync.log.level.info'));
const timeText = typeof item.ts === 'number' ? new Date(item.ts).toLocaleTimeString(i18nLanguage || undefined, { hour12: false }) : '';
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ color, flex: '0 0 auto' }}> {label}</span>
{timeText && <span style={{ color: '#8c8c8c', flex: '0 0 auto' }}>{timeText}</span>}
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{item.message}</span>
</div>
);
};
const previewSql = useMemo(() => {
if (!previewData || !previewTable) return { sqlText: '', statementCount: 0 };
const targetType = String(connections.find(c => c.id === targetConnId)?.config?.type || '');
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
return buildSqlPreview(previewData, previewTable, targetType, ops);
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
const previewHasSchemaStatements = useMemo(
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
[previewData],
);
const previewSchemaWarnings = useMemo(
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
[previewData],
);
const previewHasDataDiff = useMemo(
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
[previewData],
);
const analysisWarnings = useMemo(() => {
const items: string[] = [];
diffTables.forEach((table) => {
(table.warnings || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
(table.unsupportedObjects || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
});
return Array.from(new Set(items));
}, [diffTables]);
const isSourceQueryMode = sourceDatasetMode === 'query';
const isMigrationWorkflow = !isCompareEntry && workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
const targetType = String(targetConn?.config?.type || '').toLowerCase();
const isRedisMongoKeyspaceMigration = isMigrationWorkflow && (
(sourceType === 'redis' && targetType === 'mongodb') ||
(sourceType === 'mongodb' && targetType === 'redis')
);
const defaultMongoCollectionName = useMemo(() => {
if (sourceType === 'redis' && targetType === 'mongodb') {
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
}
if (sourceType === 'mongodb' && targetType === 'redis') {
return selectedTables[0] || `redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys`;
}
return '';
}, [sourceType, targetType, sourceDb, targetDb, sourceConn, targetConn, selectedTables]);
const modalPanelStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
}), [darkMode, disableLocalBackdropFilter]);
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
borderRadius: 18,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`,
boxShadow: darkMode ? '0 12px 32px rgba(0,0,0,0.22)' : '0 10px 24px rgba(15,23,42,0.08)',
overflow: 'hidden',
}), [darkMode, effectiveOpacity]);
const heroPanelStyle = useMemo<React.CSSProperties>(() => ({
padding: 18,
borderRadius: 18,
border: darkMode ? '1px solid rgba(255,214,102,0.12)' : '1px solid rgba(24,144,255,0.12)',
background: darkMode
? 'linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)'
: 'linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)',
marginBottom: 18,
}), [darkMode]);
const badgeStyle = useMemo<React.CSSProperties>(() => ({
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.86)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#334155',
fontSize: 12,
fontWeight: 600,
}), [darkMode]);
const quietPanelStyle = useMemo<React.CSSProperties>(() => ({
padding: 14,
borderRadius: 16,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(248,250,252,0.92)',
}), [darkMode]);
const modalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
}), []);
const modalScrollableContentStyle = useMemo<React.CSSProperties>(() => ({
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
overscrollBehavior: 'contain',
}), []);
const modalFooterBarStyle = useMemo<React.CSSProperties>(() => ({
marginTop: 18,
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
paddingTop: 12,
borderTop: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(15,23,42,0.06)',
flex: '0 0 auto',
}), [darkMode]);
const renderModalTitle = (title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{
width: 38,
height: 38,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)',
color: darkMode ? '#ffd666' : token.colorPrimary,
flexShrink: 0,
}}>
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{title}</div>
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(15,23,42,0.58)' }}>{description}</div>
</div>
</div>
);
return (
<>
<Modal
title={renderModalTitle(
isMigrationWorkflow
? tr('data_sync.title.migration_workbench')
: (isCompareEntry ? entryPresentation.title : tr('data_sync.title.sync_workbench')),
isMigrationWorkflow
? tr('data_sync.title.migration_description')
: (isCompareEntry ? entryPresentation.description : tr('data_sync.title.sync_description')),
)}
open={open}
onCancel={() => {
if (syncing) {
message.warning(tr('data_sync.message.close_blocked_running'));
return;
}
onClose();
}}
width={920}
footer={null}
destroyOnHidden
closable={!syncing}
maskClosable={!syncing}
styles={{
content: modalPanelStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 },
body: {
paddingTop: 8,
height: 760,
maxHeight: 'calc(100vh - 120px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
},
footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 },
}}
>
<div style={modalWorkspaceStyle}>
<div style={{ flex: '0 0 auto' }}>
<div style={heroPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>
{isMigrationWorkflow
? tr('data_sync.title.migration')
: (isCompareEntry ? entryPresentation.heroTitle : tr('data_sync.title.sync'))}
</div>
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
{isMigrationWorkflow
? tr('data_sync.title.migration_description')
: (isCompareEntry ? entryPresentation.heroDescription : tr('data_sync.title.sync_description'))}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<span style={badgeStyle}>
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow
? tr('data_sync.badge.migration_mode')
: (isCompareEntry ? entryPresentation.badgeText : tr('data_sync.badge.sync_mode'))}
</span>
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? tr('data_sync.badge.source_selected') : tr('data_sync.badge.source_pending')}</span>
<span style={badgeStyle}><TableOutlined /> {tr('data_sync.badge.table_count', { count: selectedTables.length || 0 })}</span>
</div>
</div>
</div>
<Steps current={currentStep} style={{ marginBottom: 24 }}>
<Step title={tr('data_sync.step.configure')} />
<Step title={tr('data_sync.step.select_tables')} />
<Step title={isCompareEntry ? entryPresentation.resultTitle : tr('data_sync.step.result')} />
</Steps>
</div>
<div style={modalScrollableContentStyle}>
{/* STEP 1: CONFIG */}
{currentStep === 0 && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 44px minmax(0, 1fr)', gap: 18, alignItems: 'stretch' }}>
<Card
title={tr('data_sync.title.source_database')}
style={shellCardStyle}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<Form layout="vertical">
<Form.Item label={tr('data_sync.field.connection')}>
<Select value={sourceConnId} onChange={handleSourceConnChange}>
{connections.map(c => <Option key={c.id} value={c.id}>{c.name} ({c.config.type})</Option>)}
</Select>
</Form.Item>
<Form.Item label={tr('data_sync.field.database')}>
<Select value={sourceDb} onChange={setSourceDb} showSearch>
{sourceDbs.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
</Form.Item>
</Form>
</Card>
<div style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ ...badgeStyle, width: 44, height: 44, borderRadius: 14, justifyContent: 'center', padding: 0 }}>
<SwapOutlined />
</div>
</div>
<Card
title={tr('data_sync.title.target_database')}
style={shellCardStyle}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<Form layout="vertical">
<Form.Item label={tr('data_sync.field.connection')}>
<Select value={targetConnId} onChange={handleTargetConnChange}>
{connections.map(c => <Option key={c.id} value={c.id}>{c.name} ({c.config.type})</Option>)}
</Select>
</Form.Item>
<Form.Item label={tr('data_sync.field.database')}>
<Select value={targetDb} onChange={setTargetDb} showSearch>
{targetDbs.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
</Form.Item>
</Form>
</Card>
</div>
<Card
title={isMigrationWorkflow
? tr('data_sync.title.migration_options')
: (isCompareEntry ? entryPresentation.optionTitle : tr('data_sync.title.sync_options'))}
style={{ ...shellCardStyle, marginTop: 18 }}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
{isCompareEntry
? tr('data_sync.compare_entry.workflow_help')
: tr('data_sync.help.workflow_type')}
</Text>
</div>
<Form layout="vertical">
{!isCompareEntry && (
<Form.Item label={tr('data_sync.field.workflow_type')}>
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync">{tr('data_sync.option.workflow.sync')}</Option>
<Option value="migration" disabled={isSourceQueryMode}>{tr('data_sync.option.workflow.migration')}</Option>
</Select>
</Form.Item>
)}
{!isSchemaCompareEntry && (
<Form.Item label={tr('data_sync.field.source_dataset_mode')}>
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table">{isCompareEntry ? tr('data_sync.compare_entry.option.source_dataset.table') : tr('data_sync.option.source_dataset.table')}</Option>
<Option value="query">{isCompareEntry ? tr('data_sync.compare_entry.option.source_dataset.query') : tr('data_sync.option.source_dataset.query')}</Option>
</Select>
</Form.Item>
)}
<Alert
type={isMigrationWorkflow || isCompareEntry ? 'info' : 'success'}
showIcon
style={{ marginBottom: 12 }}
message={isMigrationWorkflow
? tr('data_sync.alert.migration_mode')
: isSchemaCompareEntry
? tr('data_sync.compare_entry.alert.schema')
: isDataCompareEntry
? tr('data_sync.compare_entry.alert.data')
: tr('data_sync.alert.sync_mode')}
/>
{isSourceQueryMode && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={tr('data_sync.alert.query_mode')}
/>
)}
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? tr('data_sync.field.migration_content') : tr('data_sync.field.sync_content')}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data">{tr('data_sync.option.content.data')}</Option>
<Option value="schema" disabled={isSourceQueryMode}>{tr('data_sync.option.content.schema')}</Option>
<Option value="both" disabled={isSourceQueryMode}>{tr('data_sync.option.content.both')}</Option>
</Select>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? tr('data_sync.field.migration_mode') : tr('data_sync.field.sync_mode')}>
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
<Option value="insert_update">{tr('data_sync.option.sync_mode.insert_update')}</Option>
<Option value="insert_only">{tr('data_sync.option.sync_mode.insert_only')}</Option>
<Option value="full_overwrite">{tr('data_sync.option.sync_mode.full_overwrite')}</Option>
</Select>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? tr('data_sync.field.target_table_strategy') : tr('data_sync.field.target_table_requirement')}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">{tr('data_sync.option.target_strategy.existing_only')}</Option>
<Option value="auto_create_if_missing">{tr('data_sync.option.target_strategy.auto_create_if_missing')}</Option>
<Option value="smart">{tr('data_sync.option.target_strategy.smart')}</Option>
</Select>
</Form.Item>
)}
{isRedisMongoKeyspaceMigration && (
<Form.Item
label={tr('data_sync.field.mongo_collection_name')}
extra={sourceType === 'redis'
? tr('data_sync.help.mongo_collection_redis_to_mongo')
: tr('data_sync.help.mongo_collection_mongo_to_redis')}
>
<Input
value={mongoCollectionName}
onChange={(e) => setMongoCollectionName(e.target.value)}
placeholder={defaultMongoCollectionName || tr('data_sync.placeholder.mongo_collection_name')}
allowClear
maxLength={128}
/>
</Form.Item>
)}
{(!isCompareEntry || isSchemaCompareEntry) && (
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
{isSchemaCompareEntry
? tr('data_sync.compare_entry.option.auto_add_columns')
: tr('data_sync.option.auto_add_columns')}
</Checkbox>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
{tr('data_sync.option.create_indexes')}
</Checkbox>
</Form.Item>
)}
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
<Alert
type="info"
showIcon
message={tr('data_sync.alert.auto_create_planner_scope')}
style={{ marginBottom: 12 }}
/>
)}
{!isCompareEntry && !isMigrationWorkflow && (
<Alert
type="info"
showIcon
message={tr('data_sync.alert.existing_target_only')}
style={{ marginBottom: 12 }}
/>
)}
{syncContent !== 'schema' && syncMode === 'full_overwrite' && (
<Alert
type="warning"
showIcon
message={tr('data_sync.alert.full_overwrite')}
/>
)}
</Form>
</Card>
</div>
)}
{/* STEP 2: TABLES */}
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
{!isSourceQueryMode && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary">{isCompareEntry ? entryPresentation.tableSelectLabel : tr('data_sync.help.select_tables')}</Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
{tr('data_sync.option.show_same_tables')}
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={[tr('data_sync.transfer.source_tables'), tr('data_sync.transfer.selected_tables')]}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: tr('data_sync.transfer.item_unit'), itemsUnit: tr('data_sync.transfer.items_unit'), searchPlaceholder: tr('data_sync.transfer.search_placeholder'), notFoundContent: tr('data_sync.transfer.empty') }}
/>
</>
)}
{isSourceQueryMode && (
<Form layout="vertical">
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={tr('data_sync.help.source_query_mode')}
/>
<Form.Item label={tr('data_sync.field.source_query_sql')}>
<TextArea
value={sourceQuery}
onChange={(e) => setSourceQuery(e.target.value)}
rows={8}
placeholder={tr('data_sync.placeholder.source_query_sql')}
spellCheck={false}
/>
</Form.Item>
<Form.Item label={tr('data_sync.field.target_table')}>
<Select
value={selectedTables[0]}
onChange={(value) => setSelectedTables(value ? [value] : [])}
showSearch
allowClear
placeholder={tr('data_sync.placeholder.target_table')}
optionFilterProp="children"
>
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
</Select>
</Form.Item>
</Form>
)}
</div>
{diffTables.length > 0 && (
<div style={quietPanelStyle}>
<Divider orientation="left" style={{ marginTop: 0 }}>{tr('data_sync.title.compare_result')}</Divider>
{analysisWarnings.length > 0 && (
<Alert
type="warning"
showIcon
message={tr('data_sync.message.precheck_warnings')}
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{analysisWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{analysisWarnings.length > 8 && <li>{tr('data_sync.message.more_items_collapsed', { count: analysisWarnings.length - 8 })}</li>}
</ul>
}
style={{ marginBottom: 12 }}
/>
)}
<Table
size="small"
pagination={false}
rowKey={(r: any) => r.table}
dataSource={diffTables.filter(t => {
const ins = Number(t.inserts || 0);
const upd = Number(t.updates || 0);
const del = Number(t.deletes || 0);
const same = Number(t.same || 0);
const msg = String(t.message || '').trim();
const can = !!t.canSync;
const warns = Array.isArray(t.warnings) ? t.warnings.length : 0;
const unsupported = Array.isArray(t.unsupportedObjects) ? t.unsupportedObjects.length : 0;
if (showSameTables) return true;
if (!can) return true;
if (msg || warns > 0 || unsupported > 0) return true;
return ins > 0 || upd > 0 || del > 0 || same === 0;
})}
columns={[
{ title: tr('data_sync.table.table_name'), dataIndex: 'table', key: 'table', ellipsis: true },
{
title: tr('data_sync.table.target_table'),
key: 'targetTableExists',
width: 90,
render: (_: any, r: any) => r.targetTableExists ? tr('data_sync.table.target_exists') : tr('data_sync.table.target_missing')
},
{
title: tr('data_sync.table.plan'),
dataIndex: 'plannedAction',
key: 'plannedAction',
width: 220,
ellipsis: true,
render: (v: any) => String(v || '')
},
{
title: tr('data_sync.table.insert'),
key: 'inserts',
width: 90,
render: (_: any, r: any) => {
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.inserts || 0) === 0;
return (
<Checkbox checked={!!ops.insert} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}>
{Number(r.inserts || 0)}
</Checkbox>
);
}
},
{
title: tr('data_sync.table.update'),
key: 'updates',
width: 90,
render: (_: any, r: any) => {
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.updates || 0) === 0;
return (
<Checkbox checked={!!ops.update} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}>
{Number(r.updates || 0)}
</Checkbox>
);
}
},
{
title: tr('data_sync.table.delete'),
key: 'deletes',
width: 90,
render: (_: any, r: any) => {
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.deletes || 0) === 0;
return (
<Checkbox checked={!!ops.delete} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}>
{Number(r.deletes || 0)}
</Checkbox>
);
}
},
{ title: tr('data_sync.table.same'), dataIndex: 'same', key: 'same', width: 70, render: (v: any) => Number(v || 0) },
{
title: tr('data_sync.table.risk'),
key: 'warnings',
width: 220,
render: (_: any, r: any) => {
const warns = [...(Array.isArray(r.warnings) ? r.warnings : []), ...(Array.isArray(r.unsupportedObjects) ? r.unsupportedObjects : [])];
if (warns.length === 0) return '-';
return (
<div style={{ color: '#d48806', fontSize: 12, lineHeight: 1.5 }}>
{warns.slice(0, 2).map((item: string) => <div key={item}>{item}</div>)}
{warns.length > 2 && <div>{tr('data_sync.message.more_items_collapsed', { count: warns.length - 2 })}</div>}
</div>
);
}
},
{
title: tr('data_sync.table.preview'),
key: 'preview',
width: 80,
render: (_: any, r: any) => {
const can = !!r.canSync;
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
return (
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
{tr('data_sync.action.view')}
</Button>
);
}
}
]}
/>
</div>
)}
</div>
)}
{/* STEP 3: RESULT */}
{currentStep === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<Alert
message={syncing
? (isCompareEntry ? tr('data_sync.compare_entry.result.running') : tr('data_sync.result.running'))
: (syncResult?.success
? (isCompareEntry ? tr('data_sync.compare_entry.result.completed') : tr('data_sync.result.completed'))
: (isCompareEntry ? tr('data_sync.compare_entry.result.failed') : tr('data_sync.result.failed')))}
description={
syncing
? (isCompareEntry
? tr('data_sync.compare_entry.result.running_description', {
stage: syncProgress.stage || tr('data_sync.compare_entry.result.stage_fallback'),
table: syncProgress.table ? tr('data_sync.compare_entry.result.table_suffix', { table: syncProgress.table }) : '',
})
: tr('data_sync.result.running_description', {
stage: syncProgress.stage || tr('data_sync.progress.stage.executing'),
table: syncProgress.table ? tr('data_sync.result.table_suffix', { table: syncProgress.table }) : '',
}))
: (syncResult?.message || (isCompareEntry
? tr('data_sync.compare_entry.result.success_summary', {
tables: diffTables.length || syncResult?.tablesSynced || 0,
})
: tr('data_sync.result.success_summary', {
tables: syncResult?.tablesSynced || 0,
inserted: syncResult?.rowsInserted || 0,
updated: syncResult?.rowsUpdated || 0,
})))
}
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
showIcon
/>
<div style={{ marginTop: 14 }}>
<Progress
percent={syncProgress.percent}
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
format={() => `${syncProgress.current}/${syncProgress.total}`}
/>
</div>
</div>
<div style={quietPanelStyle}>
<Divider orientation="left" style={{ marginTop: 0 }}>{isCompareEntry ? tr('data_sync.compare_entry.title.analysis_log') : tr('data_sync.title.execution_log')}</Divider>
<div
ref={logBoxRef}
onScroll={() => {
const el = logBoxRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = nearBottom;
}}
style={{
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
borderRadius: 14,
padding: 12,
height: 300,
overflowY: 'auto',
fontFamily: 'var(--gn-font-mono)'
}}
>
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
</div>
</div>
</div>
)}
</div>
<div style={modalFooterBarStyle}>
{currentStep === 0 && (
<Button type="primary" onClick={nextToTables} loading={loading}>{tr('data_sync.action.next')}</Button>
)}
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>{tr('data_sync.action.previous')}</Button>
<Button
onClick={analyzeDiff}
loading={loading}
disabled={(isCompareEntry ? false : syncContent === 'schema') || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())}
style={{ marginRight: 8 }}
>
{isCompareEntry ? entryPresentation.analyzeButtonText : tr('data_sync.action.analyze_diff')}
</Button>
{isCompareEntry && (
<Button onClick={onClose}>
{entryPresentation.closeButtonText}
</Button>
)}
{!isCompareEntry && (
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
{tr('data_sync.action.start_sync')}
</Button>
)}
</>
)}
{currentStep === 2 && (
<>
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>{isCompareEntry ? tr('data_sync.compare_entry.action.return_to_compare') : tr('data_sync.action.continue_sync')}</Button>
<Button type="primary" disabled={syncing} onClick={onClose}>{isCompareEntry ? entryPresentation.closeButtonText : tr('data_sync.action.close')}</Button>
</>
)}
</div>
</div>
</Modal>
<Drawer
title={tr('data_sync.preview.title', { table: previewTable })}
styles={{ body: { background: darkMode ? 'rgba(9,13,20,0.98)' : '#f8fafc' } }}
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewTable(''); setPreviewData(null); }}
width={900}
>
{previewLoading && <Alert type="info" showIcon message={tr('data_sync.preview.loading')} />}
{!previewLoading && previewData && (
<div>
<Alert
type="info"
showIcon
message={
previewHasDataDiff
? tr('data_sync.preview.data_summary', { inserts: previewData.totalInserts || 0, updates: previewData.totalUpdates || 0, deletes: previewData.totalDeletes || 0 })
: (previewData.schemaSummary || tr('data_sync.preview.schema_statement_count', { count: previewSql.statementCount }))
}
/>
{previewSchemaWarnings.length > 0 && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message={tr('data_sync.preview.schema_warning_title')}
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{previewSchemaWarnings.length > 8 && <li>{tr('data_sync.message.more_items_collapsed', { count: previewSchemaWarnings.length - 8 })}</li>}
</ul>
}
/>
)}
<Divider />
<Tabs
items={[
...(previewHasSchemaStatements ? [{
key: 'schema',
label: tr('data_sync.preview.tab.schema', { count: Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0 }),
children: (
<div>
<Text type="secondary">
{previewData.schemaSummary || tr('data_sync.preview.schema_plan_help')}
</Text>
<pre
style={{
marginTop: 8,
marginBottom: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
? previewData.schemaStatements.join('\n')
: tr('data_sync.preview.sql.no_schema_changes')}
</pre>
</div>
)
}] : []),
...(previewHasDataDiff ? [{
key: 'insert',
label: tr('data_sync.preview.tab.insert', { count: previewData.totalInserts || 0 }),
children: (
<div>
<Text type="secondary">{isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.insert')}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
rowKey={(r: any) => r.pk}
dataSource={(previewData.inserts || []).map((r: any) => ({ ...r, key: r.pk }))}
pagination={false}
rowSelection={{
selectedRowKeys: (tableOptions[previewTable]?.selectedInsertPks || []) as any,
onChange: (keys) => updateTableOption(previewTable, 'selectedInsertPks', keys as string[]),
getCheckboxProps: () => ({ disabled: !tableOptions[previewTable]?.insert }),
}}
columns={[
{ title: previewData.pkColumn || tr('data_sync.preview.column.primary_key'), dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true },
{ title: tr('data_sync.preview.column.data'), dataIndex: 'row', key: 'row', render: (v: any) => <pre style={{ margin: 0, maxHeight: 140, overflow: 'auto' }}>{JSON.stringify(v, null, 2)}</pre> }
]}
/>
</div>
)
},
{
key: 'update',
label: tr('data_sync.preview.tab.update', { count: previewData.totalUpdates || 0 }),
children: (
<div>
<Text type="secondary">{isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.update')}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
rowKey={(r: any) => r.pk}
dataSource={(previewData.updates || []).map((r: any) => ({ ...r, key: r.pk }))}
pagination={false}
rowSelection={{
selectedRowKeys: (tableOptions[previewTable]?.selectedUpdatePks || []) as any,
onChange: (keys) => updateTableOption(previewTable, 'selectedUpdatePks', keys as string[]),
getCheckboxProps: () => ({ disabled: !tableOptions[previewTable]?.update }),
}}
columns={[
{ title: previewData.pkColumn || tr('data_sync.preview.column.primary_key'), dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true },
{ title: tr('data_sync.preview.column.changed_columns'), dataIndex: 'changedColumns', key: 'changedColumns', render: (v: any) => Array.isArray(v) ? v.join(', ') : '' },
{
title: tr('data_sync.preview.column.detail'),
key: 'detail',
width: 80,
render: (_: any, r: any) => (
<Button size="small" onClick={() => {
Modal.info({
title: tr('data_sync.preview.update_detail_title', { table: previewTable, pk: r.pk }),
width: 900,
content: (
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Title level={5}>{tr('data_sync.preview.side.source')}</Title>
<pre style={{ maxHeight: 360, overflow: 'auto', background: '#f5f5f5', padding: 8 }}>{JSON.stringify(r.source, null, 2)}</pre>
</div>
<div style={{ flex: 1 }}>
<Title level={5}>{tr('data_sync.preview.side.target')}</Title>
<pre style={{ maxHeight: 360, overflow: 'auto', background: '#f5f5f5', padding: 8 }}>{JSON.stringify(r.target, null, 2)}</pre>
</div>
</div>
)
});
}}>{tr('data_sync.action.view')}</Button>
)
}
]}
/>
</div>
)
},
{
key: 'delete',
label: tr('data_sync.preview.tab.delete', { count: previewData.totalDeletes || 0 }),
children: (
<div>
<Alert type="warning" showIcon message={tr('data_sync.preview.delete_warning')} />
<Text type="secondary">{isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.delete')}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
rowKey={(r: any) => r.pk}
dataSource={(previewData.deletes || []).map((r: any) => ({ ...r, key: r.pk }))}
pagination={false}
rowSelection={{
selectedRowKeys: (tableOptions[previewTable]?.selectedDeletePks || []) as any,
onChange: (keys) => updateTableOption(previewTable, 'selectedDeletePks', keys as string[]),
getCheckboxProps: () => ({ disabled: !tableOptions[previewTable]?.delete }),
}}
columns={[
{ title: previewData.pkColumn || tr('data_sync.preview.column.primary_key'), dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true },
{ title: tr('data_sync.preview.column.data'), dataIndex: 'row', key: 'row', render: (v: any) => <pre style={{ margin: 0, maxHeight: 140, overflow: 'auto' }}>{JSON.stringify(v, null, 2)}</pre> }
]}
/>
</div>
)
}] : []),
{
key: 'sql',
label: tr('data_sync.preview.tab.sql', { count: previewSql.statementCount }),
children: (
<div>
<Alert
type="info"
showIcon
message={
previewHasDataDiff
? (isCompareEntry ? tr('data_sync.compare_entry.preview.sql.data_help') : tr('data_sync.preview.sql.data_help'))
: (isCompareEntry ? tr('data_sync.compare_entry.preview.sql.schema_help') : tr('data_sync.preview.sql.schema_help'))
}
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">
{previewHasDataDiff
? tr('data_sync.preview.sql.statement_count', { count: previewSql.statementCount })
: tr('data_sync.preview.sql.schema_statement_count', { count: previewSql.statementCount })}
</Text>
<Button
size="small"
disabled={!previewSql.sqlText}
onClick={async () => {
try {
await navigator.clipboard.writeText(previewSql.sqlText || '');
message.success(tr('data_sync.preview.message.sql_copied'));
} catch {
message.error(tr('data_sync.preview.message.copy_failed'));
}
}}
>
{tr('data_sync.preview.action.copy_sql')}
</Button>
</div>
<pre
style={{
margin: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{previewSql.sqlText || (previewHasDataDiff ? tr('data_sync.preview.sql.no_data_sql') : tr('data_sync.preview.sql.no_schema_changes'))}
</pre>
</div>
)
}
]}
/>
</div>
)}
</Drawer>
</>
);
};
export default DataSyncModal;