feat(core): 扩展多源数据库驱动并实现数据同步引擎

- 集成 go-ora, dm, gokb 驱动,封装统一的 Database 接口实现,支持自定义 DSN 连接
- 新增 SyncEngine 同步引擎,支持基于主键的增量数据比对 (Insert/Update)
- 新增 DataSyncModal 组件,实现三步走同步向导逻辑,修复 Transfer 组件空状态显示问题
- 优化 ConnectionModal 交互逻辑,支持驱动参数动态显隐
- 引入 antd/locale/zh_CN,统一应用界面的中文本地化显示
This commit is contained in:
杨国锋
2026-02-02 19:57:41 +08:00
parent 9559291fa3
commit 7eb42aca62
16 changed files with 1950 additions and 46 deletions

View File

@@ -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>
);
};