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 : {}; 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[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(''); const [targetConnId, setTargetConnId] = useState(''); const [sourceDb, setSourceDb] = useState(''); const [targetDb, setTargetDb] = useState(''); const [sourceDbs, setSourceDbs] = useState([]); const [targetDbs, setTargetDbs] = useState([]); // Step 2: Tables const [allTables, setAllTables] = useState([]); const [selectedTables, setSelectedTables] = useState([]); const [sourceDatasetMode, setSourceDatasetMode] = useState('table'); const [sourceQuery, setSourceQuery] = useState(''); // Options const [workflowType, setWorkflowType] = useState('sync'); const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>(isSchemaCompareEntry ? 'schema' : 'data'); const [syncMode, setSyncMode] = useState('insert_update'); const [autoAddColumns, setAutoAddColumns] = useState(true); const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only'); const [createIndexes, setCreateIndexes] = useState(false); const [mongoCollectionName, setMongoCollectionName] = useState(''); const [showSameTables, setShowSameTables] = useState(false); const [analyzing, setAnalyzing] = useState(false); const [diffTables, setDiffTables] = useState([]); const [tableOptions, setTableOptions] = useState>({}); const [previewOpen, setPreviewOpen] = useState(false); const [previewTable, setPreviewTable] = useState(''); const [previewLoading, setPreviewLoading] = useState(false); const [previewData, setPreviewData] = useState(null); // Step 3: Result const [syncResult, setSyncResult] = useState(null); const [syncing, setSyncing] = useState(false); const [syncLogs, setSyncLogs] = useState([]); 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(''); const logBoxRef = useRef(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 = {}; 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((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 (
● {label} {timeText && {timeText}} {item.message}
); }; 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(() => ({ 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(() => ({ 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(() => ({ 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(() => ({ 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(() => ({ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, }), []); const modalScrollableContentStyle = useMemo(() => ({ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden', paddingRight: 4, overscrollBehavior: 'contain', }), []); const modalFooterBarStyle = useMemo(() => ({ 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) => (
{isMigrationWorkflow ? : }
{title}
{description}
); return ( <> { 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 }, }} >
{isMigrationWorkflow ? tr('data_sync.title.migration') : (isCompareEntry ? entryPresentation.heroTitle : tr('data_sync.title.sync'))}
{isMigrationWorkflow ? tr('data_sync.title.migration_description') : (isCompareEntry ? entryPresentation.heroDescription : tr('data_sync.title.sync_description'))}
{isMigrationWorkflow ? : } {isMigrationWorkflow ? tr('data_sync.badge.migration_mode') : (isCompareEntry ? entryPresentation.badgeText : tr('data_sync.badge.sync_mode'))} {sourceConnId ? tr('data_sync.badge.source_selected') : tr('data_sync.badge.source_pending')} {tr('data_sync.badge.table_count', { count: selectedTables.length || 0 })}
{/* STEP 1: CONFIG */} {currentStep === 0 && (
{isCompareEntry ? tr('data_sync.compare_entry.workflow_help') : tr('data_sync.help.workflow_type')}
{!isCompareEntry && ( )} {!isSchemaCompareEntry && ( )} {isSourceQueryMode && ( )} {!isCompareEntry && ( )} {!isCompareEntry && ( )} {!isCompareEntry && ( )} {isRedisMongoKeyspaceMigration && ( setMongoCollectionName(e.target.value)} placeholder={defaultMongoCollectionName || tr('data_sync.placeholder.mongo_collection_name')} allowClear maxLength={128} /> )} {(!isCompareEntry || isSchemaCompareEntry) && ( setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> {isSchemaCompareEntry ? tr('data_sync.compare_entry.option.auto_add_columns') : tr('data_sync.option.auto_add_columns')} )} {!isCompareEntry && ( setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}> {tr('data_sync.option.create_indexes')} )} {isMigrationWorkflow && targetTableStrategy !== 'existing_only' && ( )} {!isCompareEntry && !isMigrationWorkflow && ( )} {syncContent !== 'schema' && syncMode === 'full_overwrite' && ( )}
)} {/* STEP 2: TABLES */} {currentStep === 1 && (
{!isSourceQueryMode && ( <>
{isCompareEntry ? entryPresentation.tableSelectLabel : tr('data_sync.help.select_tables')} setShowSameTables(e.target.checked)}> {tr('data_sync.option.show_same_tables')}
({ 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 && (