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 { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; const { Title, Text } = Typography; const { Step } = Steps; const { Option } = Select; 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; 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 === '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 `'${value.toISOString().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 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 statements: string[] = []; 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) => toSqlLiteral(row[c], dbType)).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)} = ${toSqlLiteral(source[c], dbType)}`) .join(', '); statements.push( `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, ); }); } 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)} = ${toSqlLiteral(pk, dbType)};`, ); }); } 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); // 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([]); // 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) => ({ ...conn.config, port: Number((conn.config as any).port), password: conn.config.password || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, 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(''); setSelectedTables([]); 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]); 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 conn = connections.find(c => c.id === sourceConnId); if (conn) { const config = normalizeConnConfig(conn, sourceDb); const res = await DBGetTables(config as any, sourceDb); 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[]); 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 () => { if (selectedTables.length === 0) return; 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 = { sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), tables: selectedTables, content: syncContent, mode: "insert_update", autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName: mongoCollectionName.trim(), 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 = { sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), tables: selectedTables, content: "data", mode: "insert_update", autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName: mongoCollectionName.trim(), }; 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 () => { 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 = { sourceConfig: { ...sConn.config, port: Number((sConn.config as any).port), password: sConn.config.password || "", useSSH: sConn.config.useSSH || false, ssh: sConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, database: sourceDb, }, targetConfig: { ...tConn.config, port: Number((tConn.config as any).port), password: tConn.config.password || "", useSSH: tConn.config.useSSH || false, ssh: tConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, database: targetDb, }, tables: selectedTables, content: syncContent, mode: syncMode, autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName: mongoCollectionName.trim(), 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 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 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: darkMode ? 'blur(18px)' : 'none', }), [darkMode]); 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 && (
先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。
{isRedisMongoKeyspaceMigration && ( setMongoCollectionName(e.target.value)} placeholder={defaultMongoCollectionName || '请输入 Mongo 集合名'} allowClear maxLength={128} /> )} setAutoAddColumns(e.target.checked)}> 自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase) setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}> 自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效) {isMigrationWorkflow && targetTableStrategy !== 'existing_only' && ( )} {!isMigrationWorkflow && ( )} {syncContent !== 'schema' && syncMode === 'full_overwrite' && ( )}
)} {/* STEP 2: TABLES */} {currentStep === 1 && (
请选择需要同步的表: 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: '暂无数据' }} />
{diffTables.length > 0 && (
对比结果 {analysisWarnings.length > 0 && ( {analysisWarnings.slice(0, 8).map((item) =>
  • {item}
  • )} {analysisWarnings.length > 8 &&
  • 还有 {analysisWarnings.length - 8} 项未展开
  • } } style={{ marginBottom: 12 }} /> )} 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: '表名', dataIndex: 'table', key: 'table', ellipsis: true }, { title: '目标表', key: 'targetTableExists', width: 90, render: (_: any, r: any) => r.targetTableExists ? '已存在' : '不存在' }, { title: '计划', dataIndex: 'plannedAction', key: 'plannedAction', width: 220, ellipsis: true, render: (v: any) => String(v || '') }, { title: '插入', 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 ( updateTableOption(r.table, 'insert', e.target.checked)}> {Number(r.inserts || 0)} ); } }, { title: '更新', 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 ( updateTableOption(r.table, 'update', e.target.checked)}> {Number(r.updates || 0)} ); } }, { title: '删除', 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 ( updateTableOption(r.table, 'delete', e.target.checked)}> {Number(r.deletes || 0)} ); } }, { title: '相同', dataIndex: 'same', key: 'same', width: 70, render: (v: any) => Number(v || 0) }, { title: '风险', 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 (
    {warns.slice(0, 2).map((item: string) =>
    {item}
    )} {warns.length > 2 &&
    还有 {warns.length - 2} 项
    }
    ); } }, { title: '预览', 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; return ( ); } } ]} /> )} )} {/* STEP 3: RESULT */} {currentStep === 2 && (
    `${syncProgress.current}/${syncProgress.total}`} />
    执行日志
    { 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: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace' }} > {syncLogs.map((item, i: number) =>
    {renderSyncLogItem(item)}
    )}
    )}
    {currentStep === 0 && ( )} {currentStep === 1 && ( <> )} {currentStep === 2 && ( <> )}
    { setPreviewOpen(false); setPreviewTable(''); setPreviewData(null); }} width={900} > {previewLoading && } {!previewLoading && previewData && (
    未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。
    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 || '主键', dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true }, { title: '数据', dataIndex: 'row', key: 'row', render: (v: any) =>
    {JSON.stringify(v, null, 2)}
    } ]} /> ) }, { key: 'update', label: `更新(${previewData.totalUpdates || 0})`, children: (
    未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。
    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 || '主键', dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true }, { title: '变更字段', dataIndex: 'changedColumns', key: 'changedColumns', render: (v: any) => Array.isArray(v) ? v.join(', ') : '' }, { title: '详情', key: 'detail', width: 80, render: (_: any, r: any) => ( ) } ]} /> ) }, { key: 'delete', label: `删除(${previewData.totalDeletes || 0})`, children: (
    未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。
    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 || '主键', dataIndex: 'pk', key: 'pk', width: 200, ellipsis: true }, { title: '数据', dataIndex: 'row', key: 'row', render: (v: any) =>
    {JSON.stringify(v, null, 2)}
    } ]} /> ) }, { key: 'sql', label: `SQL(${previewSql.statementCount})`, children: (
    共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)
                                            {previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
                                        
    ) } ]} /> )} ); }; export default DataSyncModal;