mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 11:01:33 +08:00
✨ feat(core): 扩展多源数据库驱动并实现数据同步引擎
- 集成 go-ora, dm, gokb 驱动,封装统一的 Database 接口实现,支持自定义 DSN 连接 - 新增 SyncEngine 同步引擎,支持基于主键的增量数据比对 (Insert/Update) - 新增 DataSyncModal 组件,实现三步走同步向导逻辑,修复 Transfer 组件空状态显示问题 - 优化 ConnectionModal 交互逻辑,支持驱动参数动态显隐 - 引入 antd/locale/zh_CN,统一应用界面的中文本地化显示
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined, SettingOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
@@ -13,6 +15,7 @@ const { Sider, Content } = Layout;
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore();
|
||||
|
||||
@@ -77,6 +80,12 @@ function App() {
|
||||
};
|
||||
|
||||
const settingsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'sync',
|
||||
label: '数据同步',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
onClick: () => setIsSyncModalOpen(true)
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: '导入连接配置',
|
||||
@@ -216,6 +225,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
@@ -292,6 +302,10 @@ function App() {
|
||||
onClose={handleCloseModal}
|
||||
initialValues={editingConnection}
|
||||
/>
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Ghost Resize Line for Sidebar */}
|
||||
<div
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select, Alert } from 'antd';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLConnect, MySQLGetDatabases } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
const { Text } = Typography;
|
||||
|
||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -19,6 +24,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setTestResult(null); // Reset test result
|
||||
setDbList([]);
|
||||
if (initialValues) {
|
||||
// Edit mode: Go directly to step 2
|
||||
setStep(2);
|
||||
form.setFieldsValue({
|
||||
type: initialValues.config.type,
|
||||
name: initialValues.name,
|
||||
@@ -34,10 +41,14 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
sshUser: initialValues.config.ssh?.user,
|
||||
sshPassword: initialValues.config.ssh?.password,
|
||||
sshKeyPath: initialValues.config.ssh?.keyPath,
|
||||
driver: (initialValues.config as any).driver,
|
||||
dsn: (initialValues.config as any).dsn
|
||||
});
|
||||
setUseSSH(initialValues.config.useSSH || false);
|
||||
setDbType(initialValues.config.type);
|
||||
} else {
|
||||
// Create mode: Start at step 1
|
||||
setStep(1);
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setDbType('mysql');
|
||||
@@ -52,7 +63,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
|
||||
const config = await buildConfig(values);
|
||||
|
||||
// Use Connect to verify before saving
|
||||
const res = await MySQLConnect(config as any);
|
||||
setLoading(false);
|
||||
|
||||
@@ -75,6 +85,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
onClose();
|
||||
} else {
|
||||
message.error('连接失败: ' + res.message);
|
||||
@@ -88,13 +99,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
setTestResult(null); // Clear previous result
|
||||
setTestResult(null);
|
||||
const config = await buildConfig(values);
|
||||
const res = await (window as any).go.app.App.TestConnection(config);
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
setTestResult({ type: 'success', message: res.message });
|
||||
// Fetch DB List on success
|
||||
const dbRes = await MySQLGetDatabases(config as any);
|
||||
if (dbRes.success) {
|
||||
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
|
||||
@@ -119,35 +129,70 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
|
||||
return {
|
||||
type: values.type,
|
||||
host: values.host,
|
||||
host: values.host || "",
|
||||
port: Number(values.port || 0),
|
||||
user: values.user || "",
|
||||
password: values.password || "",
|
||||
database: values.database || "",
|
||||
useSSH: !!values.useSSH,
|
||||
ssh: sshConfig
|
||||
ssh: sshConfig,
|
||||
driver: values.driver,
|
||||
dsn: values.dsn
|
||||
};
|
||||
};
|
||||
|
||||
const isSqlite = dbType === 'sqlite';
|
||||
const handleTypeSelect = (type: string) => {
|
||||
setDbType(type);
|
||||
form.setFieldsValue({ type: type });
|
||||
|
||||
// Auto-fill default port
|
||||
let defaultPort = 3306;
|
||||
switch (type) {
|
||||
case 'mysql': defaultPort = 3306; break;
|
||||
case 'postgres': defaultPort = 5432; break;
|
||||
case 'oracle': defaultPort = 1521; break;
|
||||
case 'dameng': defaultPort = 5236; break;
|
||||
case 'kingbase': defaultPort = 54321; break;
|
||||
default: defaultPort = 3306;
|
||||
}
|
||||
if (type !== 'sqlite' && type !== 'custom') {
|
||||
form.setFieldsValue({ port: defaultPort });
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={initialValues ? "编辑连接" : "新建连接"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
footer={[
|
||||
<Button key="test" loading={loading} onClick={handleTest}>测试连接</Button>,
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
||||
]}
|
||||
width={600}
|
||||
zIndex={10001} // Increase z-index
|
||||
destroyOnHidden // Reset on close
|
||||
maskClosable={false} // Prevent accidental close by clicking mask, user must click X or Cancel
|
||||
>
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const isSqlite = dbType === 'sqlite';
|
||||
const isCustom = dbType === 'custom';
|
||||
|
||||
const dbTypes = [
|
||||
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
||||
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
||||
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
|
||||
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
|
||||
];
|
||||
|
||||
const renderStep1 = () => (
|
||||
<Row gutter={[16, 16]}>
|
||||
{dbTypes.map(item => (
|
||||
<Col span={8} key={item.key}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => handleTypeSelect(item.key)}
|
||||
style={{ textAlign: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>{item.icon}</div>
|
||||
<Text strong>{item.name}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
|
||||
const renderStep2 = () => (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -155,22 +200,28 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
onValuesChange={(changed) => {
|
||||
if (testResult) setTestResult(null); // Clear result on change
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="数据库类型" style={{ width: 120 }}>
|
||||
<Select>
|
||||
<Select.Option value="mysql">MySQL</Select.Option>
|
||||
<Select.Option value="postgres">PostgreSQL</Select.Option>
|
||||
<Select.Option value="sqlite">SQLite</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="连接名称" style={{ flex: 1 }}>
|
||||
<Input placeholder="例如:本地测试库" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* Hidden Type Field to keep form value synced */}
|
||||
<Form.Item name="type" hidden><Input /></Form.Item>
|
||||
|
||||
<Form.Item name="name" label="连接名称">
|
||||
<Input placeholder="例如:本地测试库" />
|
||||
</Form.Item>
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
|
||||
<Input placeholder="例如: mysql, postgres" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
|
||||
<Input.TextArea rows={3} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="host" label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"} rules={[{ required: true, message: '请输入地址/路径' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"} />
|
||||
@@ -233,16 +284,52 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{testResult && (
|
||||
</>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
message={testResult.message}
|
||||
type={testResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
const getFooter = () => {
|
||||
if (step === 1) {
|
||||
return [
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>
|
||||
];
|
||||
}
|
||||
return [
|
||||
!initialValues && <Button key="back" onClick={() => setStep(1)} style={{ float: 'left' }}>上一步</Button>,
|
||||
<Button key="test" loading={loading} onClick={handleTest}>测试连接</Button>,
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
||||
];
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (step === 1) return "选择数据源类型";
|
||||
const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType;
|
||||
return initialValues ? "编辑连接" : `新建 ${typeName} 连接`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={getTitle()}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={getFooter()}
|
||||
width={step === 1 ? 700 : 600}
|
||||
zIndex={10001}
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
>
|
||||
{step === 1 ? renderStep1() : renderStep2()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
226
frontend/src/components/DataSyncModal.tsx
Normal file
226
frontend/src/components/DataSyncModal.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, DBGetTables, DataSync } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
import { connection } from '../../wailsjs/go/models';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
|
||||
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<string>('');
|
||||
const [targetConnId, setTargetConnId] = useState<string>('');
|
||||
const [sourceDb, setSourceDb] = useState<string>('');
|
||||
const [targetDb, setTargetDb] = useState<string>('');
|
||||
|
||||
const [sourceDbs, setSourceDbs] = useState<string[]>([]);
|
||||
const [targetDbs, setTargetDbs] = useState<string[]>([]);
|
||||
|
||||
// Step 2: Tables
|
||||
const [allTables, setAllTables] = useState<string[]>([]);
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
|
||||
// Step 3: Result
|
||||
const [syncResult, setSyncResult] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentStep(0);
|
||||
setSourceConnId('');
|
||||
setTargetConnId('');
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setSelectedTables([]);
|
||||
setSyncResult(null);
|
||||
}
|
||||
}, [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(conn.config 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(conn.config 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 = { ...conn.config, database: 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 runSync = async () => {
|
||||
setLoading(true);
|
||||
const sConn = connections.find(c => c.id === sourceConnId)!;
|
||||
const tConn = connections.find(c => c.id === targetConnId)!;
|
||||
|
||||
const config = {
|
||||
sourceConfig: { ...sConn.config, database: sourceDb },
|
||||
targetConfig: { ...tConn.config, database: targetDb },
|
||||
tables: selectedTables,
|
||||
mode: "insert_update"
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
setSyncResult(res);
|
||||
setCurrentStep(2);
|
||||
} catch (e) {
|
||||
message.error("Sync execution failed");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="数据同步"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
||||
<Step title="配置源与目标" />
|
||||
<Step title="选择表" />
|
||||
<Step title="执行结果" />
|
||||
</Steps>
|
||||
|
||||
{/* STEP 1: CONFIG */}
|
||||
{currentStep === 0 && (
|
||||
<div style={{ display: 'flex', gap: 24, justifyContent: 'center' }}>
|
||||
<Card title="源数据库" style={{ width: 350 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={sourceConnId} onChange={handleSourceConnChange}>
|
||||
{connections.map(c => <Option key={c.id} value={c.id}>{c.name} ({c.config.type})</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="数据库">
|
||||
<Select value={sourceDb} onChange={setSourceDb} showSearch>
|
||||
{sourceDbs.map(d => <Option key={d} value={d}>{d}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>至</div>
|
||||
<Card title="目标数据库" style={{ width: 350 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={targetConnId} onChange={handleTargetConnChange}>
|
||||
{connections.map(c => <Option key={c.id} value={c.id}>{c.name} ({c.config.type})</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="数据库">
|
||||
<Select value={targetDb} onChange={setTargetDb} showSearch>
|
||||
{targetDbs.map(d => <Option key={d} value={d}>{d}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: TABLES */}
|
||||
{currentStep === 1 && (
|
||||
<div style={{ height: 400 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 350, height: 350, marginTop: 12 }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: RESULT */}
|
||||
{currentStep === 2 && syncResult && (
|
||||
<div>
|
||||
<Alert
|
||||
message={syncResult.success ? "同步完成" : "同步失败"}
|
||||
description={syncResult.message || `成功同步 ${syncResult.tablesSynced} 张表. 插入: ${syncResult.rowsInserted}, 更新: ${syncResult.rowsUpdated}`}
|
||||
type={syncResult.success ? "success" : "error"}
|
||||
showIcon
|
||||
/>
|
||||
<Divider orientation="left">日志</Divider>
|
||||
<div style={{ background: '#f5f5f5', padding: 12, height: 300, overflowY: 'auto', fontFamily: 'monospace' }}>
|
||||
{syncResult.logs.map((log: string, i: number) => <div key={i}>{log}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>下一步</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button type="primary" onClick={runSync} loading={loading} disabled={selectedTables.length === 0}>开始同步</Button>
|
||||
</>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>继续同步</Button>
|
||||
<Button type="primary" onClick={onClose}>关闭</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSyncModal;
|
||||
3
frontend/wailsjs/go/app/App.d.ts
vendored
3
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {connection} from '../models';
|
||||
import {sync} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -26,6 +27,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
|
||||
|
||||
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -50,6 +50,10 @@ export function DBShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSync(arg1) {
|
||||
return window['go']['app']['App']['DataSync'](arg1);
|
||||
}
|
||||
|
||||
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export namespace connection {
|
||||
database: string;
|
||||
useSSH: boolean;
|
||||
ssh: SSHConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConnectionConfig(source);
|
||||
@@ -92,6 +94,8 @@ export namespace connection {
|
||||
this.database = source["database"];
|
||||
this.useSSH = source["useSSH"];
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -134,3 +138,68 @@ export namespace connection {
|
||||
|
||||
}
|
||||
|
||||
export namespace sync {
|
||||
|
||||
export class SyncConfig {
|
||||
sourceConfig: connection.ConnectionConfig;
|
||||
targetConfig: connection.ConnectionConfig;
|
||||
tables: string[];
|
||||
mode: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourceConfig = this.convertValues(source["sourceConfig"], connection.ConnectionConfig);
|
||||
this.targetConfig = this.convertValues(source["targetConfig"], connection.ConnectionConfig);
|
||||
this.tables = source["tables"];
|
||||
this.mode = source["mode"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SyncResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
logs: string[];
|
||||
tablesSynced: number;
|
||||
rowsInserted: number;
|
||||
rowsUpdated: number;
|
||||
rowsDeleted: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.success = source["success"];
|
||||
this.message = source["message"];
|
||||
this.logs = source["logs"];
|
||||
this.tablesSynced = source["tablesSynced"];
|
||||
this.rowsInserted = source["rowsInserted"];
|
||||
this.rowsUpdated = source["rowsUpdated"];
|
||||
this.rowsDeleted = source["rowsDeleted"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user