import React, { useState, useEffect, useRef } from 'react'; import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd'; 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'; 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; }; type TableOps = { insert: boolean; update: boolean; delete: boolean; selectedInsertPks?: string[]; selectedUpdatePks?: string[]; selectedDeletePks?: string[]; }; const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const connections = useStore((state) => state.connections); const [currentStep, setCurrentStep] = useState(0); const [loading, setLoading] = useState(false); // 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 [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data'); const [syncMode, setSyncMode] = useState('insert_update'); const [autoAddColumns, setAutoAddColumns] = useState(true); 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([]); setSyncContent('data'); setSyncMode('insert_update'); setAutoAddColumns(true); 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]); 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) { setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username)); } } 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) { setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username)); } } 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 tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]); 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, 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, }; 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, 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}
); }; return ( <> { if (syncing) { message.warning("同步执行中,暂不支持关闭"); return; } onClose(); }} width={800} footer={null} destroyOnHidden closable={!syncing} maskClosable={!syncing} > {/* STEP 1: CONFIG */} {currentStep === 0 && (
setAutoAddColumns(e.target.checked)}> 自动补齐目标表缺失字段(仅 MySQL 目标) {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: 350, height: 280, marginTop: 0 }} locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }} /> {diffTables.length > 0 && (
对比结果 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; if (showSameTables) return true; if (!can) return true; if (msg) return true; return ins > 0 || upd > 0 || del > 0 || same === 0; })} columns={[ { title: '表名', dataIndex: 'table', key: 'table', ellipsis: true }, { 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: '消息', dataIndex: 'message', key: 'message', ellipsis: true, render: (v: any) => (v ? String(v) : '') }, { 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: '#f5f5f5', padding: 12, height: 300, overflowY: 'auto', fontFamily: '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)}
} ]} /> ) } ]} /> )} ); }; export default DataSyncModal;