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 { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert'; import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest'; 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 => { const raw = String(ident || '').trim(); if (!raw) return raw; const t = String(dbType || '').toLowerCase(); if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') { return `\`${raw.replace(/`/g, '``')}\``; } if (t === 'sqlserver') { return `[${raw.replace(/]/g, ']]')}]`; } return `"${raw.replace(/"/g, '""')}"`; }; const quoteSqlTable = (dbType: string, tableName: string): string => { const raw = String(tableName || '').trim(); if (!raw) return raw; if (!raw.includes('.')) return quoteSqlIdent(dbType, raw); return raw .split('.') .map((part) => quoteSqlIdent(dbType, part)) .join('.'); }; 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 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 }> = ({ open, onClose }) => { 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'>('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' ? 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 () => { offLog(); 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('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]); useEffect(() => { 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); } } }, [workflowType]); 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) { message.error("Failed to fetch source databases"); } 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) { message.error("Failed to fetch target databases"); } 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); } } } catch (e) { message.error("Failed to fetch tables"); } 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(selectionError); if (!sourceConnId || !targetConnId) return message.error("Select connections first"); if (!sourceDb || !targetDb) return message.error("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: '差异分析' }); const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, 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("差异分析完成"); } else { message.error(res.message || "差异分析失败"); } } catch (e: any) { message.error("差异分析失败: " + (e?.message || "")); } 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), 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 || "加载差异预览失败"); } } catch (e: any) { message.error("加载差异预览失败: " + (e?.message || "")); } setPreviewLoading(false); }; const runSync = async () => { const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent }); if (selectionError) { message.error(selectionError); return; } if (syncContent !== 'schema' && diffTables.length === 0) { message.error("请先对比差异,再开始同步"); return; } if (syncContent !== 'schema' && syncMode === 'full_overwrite') { const ok = await new Promise((resolve) => { Modal.confirm({ title: '确认全量覆盖', content: '全量覆盖会清空目标表数据后再插入,请确认已备份目标库。', okText: '继续执行', cancelText: '取消', 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: '准备开始', }); const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, 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 }; if (msg.includes('跳过') || msg.includes('警告')) return { level: 'warn', message: msg }; return { level: 'info', message: msg }; }); }); } } catch (e) { message.error("Sync execution failed"); setSyncResult({ success: false, message: "同步执行失败", 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' ? '错误' : (level === 'warn' ? '警告' : '信息'); const timeText = typeof item.ts === 'number' ? new Date(item.ts).toLocaleTimeString('zh-CN', { 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 = 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("同步执行中,暂不支持关闭"); 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 ? '跨数据源迁移' : '数据同步'}
{isMigrationWorkflow ? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。' : '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
{isMigrationWorkflow ? : } {isMigrationWorkflow ? '迁移模式' : '同步模式'} {sourceConnId ? '已选源连接' : '待选源连接'} {selectedTables.length || 0} 张表
{/* STEP 1: CONFIG */} {currentStep === 0 && (
先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。
{isSourceQueryMode && ( )} {isRedisMongoKeyspaceMigration && ( setMongoCollectionName(e.target.value)} placeholder={defaultMongoCollectionName || '请输入 Mongo 集合名'} allowClear maxLength={128} /> )} setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> 自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持) setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}> 自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效) {isMigrationWorkflow && targetTableStrategy !== 'existing_only' && ( )} {!isMigrationWorkflow && ( )} {syncContent !== 'schema' && syncMode === 'full_overwrite' && ( )}
)} {/* STEP 2: TABLES */} {currentStep === 1 && (
{!isSourceQueryMode && ( <>
请选择需要同步的表: setShowSameTables(e.target.checked)}> 显示相同表
({ key: t, title: t }))} titles={['源表', '已选表']} 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: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }} /> )} {isSourceQueryMode && (