mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-19 22:15:12 +08:00
✨ feat(connection): 增强连接管理与交互体验
- 新增测试连接功能,修复底层驱动假成功问题,确保密码/端口验证准确 - 支持导入/导出连接配置(JSON),便于迁移与备份 - 优化侧边栏:实现虚拟滚动解决卡顿,增加数据库筛选与断开连接重连机制 - 优化交互:改进右键菜单体验(全行触发/禁用选文),完善新建查询的上下文自动关联 - 界面调整:精简连接弹窗,移除冗余的默认数据库输入
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select, Alert } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLConnect } from '../../wailsjs/go/app/App';
|
||||
import { MySQLConnect, MySQLGetDatabases } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
|
||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
|
||||
@@ -9,11 +9,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
const updateConnection = useStore((state) => state.updateConnection);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTestResult(null); // Reset test result
|
||||
setDbList([]);
|
||||
if (initialValues) {
|
||||
form.setFieldsValue({
|
||||
type: initialValues.config.type,
|
||||
@@ -23,6 +27,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
user: initialValues.config.user,
|
||||
password: initialValues.config.password,
|
||||
database: initialValues.config.database,
|
||||
includeDatabases: initialValues.includeDatabases,
|
||||
useSSH: initialValues.config.useSSH,
|
||||
sshHost: initialValues.config.ssh?.host,
|
||||
sshPort: initialValues.config.ssh?.port,
|
||||
@@ -45,25 +50,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const sshConfig = values.useSSH ? {
|
||||
host: values.sshHost,
|
||||
port: Number(values.sshPort),
|
||||
user: values.sshUser,
|
||||
password: values.sshPassword || "",
|
||||
keyPath: values.sshKeyPath || ""
|
||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||
|
||||
const config = {
|
||||
type: values.type,
|
||||
host: values.host,
|
||||
port: Number(values.port || 0),
|
||||
user: values.user || "",
|
||||
password: values.password || "",
|
||||
database: values.database || "",
|
||||
useSSH: !!values.useSSH,
|
||||
ssh: sshConfig
|
||||
};
|
||||
const config = await buildConfig(values);
|
||||
|
||||
// Use Connect to verify before saving
|
||||
const res = await MySQLConnect(config as any);
|
||||
setLoading(false);
|
||||
|
||||
@@ -71,7 +60,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
|
||||
config: config
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases
|
||||
};
|
||||
|
||||
if (initialValues) {
|
||||
@@ -94,6 +84,51 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
setTestResult(null); // Clear previous result
|
||||
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);
|
||||
setDbList(dbs);
|
||||
}
|
||||
} else {
|
||||
setTestResult({ type: 'error', message: "测试失败: " + res.message });
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildConfig = async (values: any) => {
|
||||
const sshConfig = values.useSSH ? {
|
||||
host: values.sshHost,
|
||||
port: Number(values.sshPort),
|
||||
user: values.sshUser,
|
||||
password: values.sshPassword || "",
|
||||
keyPath: values.sshKeyPath || ""
|
||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||
|
||||
return {
|
||||
type: values.type,
|
||||
host: values.host,
|
||||
port: Number(values.port || 0),
|
||||
user: values.user || "",
|
||||
password: values.password || "",
|
||||
database: values.database || "",
|
||||
useSSH: !!values.useSSH,
|
||||
ssh: sshConfig
|
||||
};
|
||||
};
|
||||
|
||||
const isSqlite = dbType === 'sqlite';
|
||||
|
||||
return (
|
||||
@@ -103,8 +138,11 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
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
|
||||
@@ -115,6 +153,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
layout="vertical"
|
||||
initialValues={{ type: 'mysql', host: 'localhost', port: 3306, user: 'root', useSSH: false, sshPort: 22 }}
|
||||
onValuesChange={(changed) => {
|
||||
if (testResult) setTestResult(null); // Clear result on change
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
}}
|
||||
@@ -155,8 +194,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
)}
|
||||
|
||||
{!isSqlite && (
|
||||
<Form.Item name="database" label="默认数据库 (可选)">
|
||||
<Input />
|
||||
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
|
||||
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -193,6 +234,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
message={testResult.message}
|
||||
type={testResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
@@ -46,6 +46,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
|
||||
// Virtual Scroll State
|
||||
const [treeHeight, setTreeHeight] = useState(500);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeContainerRef.current) return;
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
setTreeHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(treeContainerRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Connection Status State: key -> 'success' | 'error'
|
||||
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
|
||||
@@ -87,7 +104,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
})));
|
||||
}, [connections]);
|
||||
|
||||
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[]): TreeNode[] => {
|
||||
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||||
return list.map(node => {
|
||||
if (node.key === key) {
|
||||
return { ...node, children };
|
||||
@@ -112,7 +129,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const res = await MySQLGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||||
const dbs = (res.data as any[]).map((row: any) => ({
|
||||
let dbs = (res.data as any[]).map((row: any) => ({
|
||||
title: row.Database || row.database,
|
||||
key: `${conn.id}-${row.Database || row.database}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
@@ -120,6 +137,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
dataRef: { ...conn, dbName: row.Database || row.database },
|
||||
isLeaf: false,
|
||||
}));
|
||||
|
||||
// Filter databases if configured
|
||||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
|
||||
}
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
@@ -433,27 +456,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return loop(treeData);
|
||||
}, [searchValue, treeData]);
|
||||
|
||||
const titleRender = (node: any) => {
|
||||
// Determine status
|
||||
let status: 'success' | 'error' | 'default' = 'default';
|
||||
const getNodeMenuItems = (node: any): MenuProps['items'] => {
|
||||
if (node.type === 'connection') {
|
||||
if (connectionStates[node.key] === 'success') status = 'success';
|
||||
else if (connectionStates[node.key] === 'error') status = 'error';
|
||||
} else if (node.type === 'database') {
|
||||
if (connectionStates[node.key] === 'success') status = 'success';
|
||||
else if (connectionStates[node.key] === 'error') status = 'error';
|
||||
}
|
||||
|
||||
// Override if active context? (Optional, user asked for "connected" status)
|
||||
// If we want to show "Active" as Green even if not loaded?
|
||||
// Let's stick to "Connected" state derived from successful load.
|
||||
|
||||
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
||||
<Badge status={status} style={{ marginRight: 8 }} />
|
||||
) : null;
|
||||
|
||||
if (node.type === 'connection') {
|
||||
const items: MenuProps['items'] = [
|
||||
return [
|
||||
{
|
||||
key: 'new-db',
|
||||
label: '新建数据库',
|
||||
@@ -498,16 +503,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
label: '断开连接',
|
||||
icon: <DisconnectOutlined />,
|
||||
onClick: () => {
|
||||
// Reset status
|
||||
setConnectionStates(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[node.key];
|
||||
return next;
|
||||
});
|
||||
// Collapse node
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key));
|
||||
// Clear children
|
||||
setTreeData(origin => updateTreeData(origin, node.key, []));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
},
|
||||
@@ -525,13 +528,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
}
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{statusBadge}{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
} else if (node.type === 'database') {
|
||||
const items: MenuProps['items'] = [
|
||||
return [
|
||||
{
|
||||
key: 'new-table',
|
||||
label: '新建表',
|
||||
@@ -550,16 +548,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
label: '关闭数据库',
|
||||
icon: <DisconnectOutlined />,
|
||||
onClick: () => {
|
||||
// Reset status
|
||||
setConnectionStates(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[node.key];
|
||||
return next;
|
||||
});
|
||||
// Collapse node
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key));
|
||||
// Clear children
|
||||
setTreeData(origin => updateTreeData(origin, node.key, []));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -583,13 +579,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => handleRunSQLFile(node)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{statusBadge}{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
} else if (node.type === 'table') {
|
||||
const contextMenu: MenuProps['items'] = [
|
||||
return [
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'design-table',
|
||||
label: '设计表',
|
||||
@@ -623,14 +629,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: contextMenu, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
return <span title={node.title}>{node.title}</span>;
|
||||
return [];
|
||||
};
|
||||
|
||||
const titleRender = (node: any) => {
|
||||
let status: 'success' | 'error' | 'default' = 'default';
|
||||
if (node.type === 'connection' || node.type === 'database') {
|
||||
if (connectionStates[node.key] === 'success') status = 'success';
|
||||
else if (connectionStates[node.key] === 'error') status = 'error';
|
||||
}
|
||||
|
||||
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
||||
<Badge status={status} style={{ marginRight: 8 }} />
|
||||
) : null;
|
||||
|
||||
return <span title={node.title}>{statusBadge}{node.title}</span>;
|
||||
};
|
||||
|
||||
const onRightClick = ({ event, node }: any) => {
|
||||
const items = getNodeMenuItems(node);
|
||||
if (items && items.length > 0) {
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
items
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -638,7 +663,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<Search placeholder="搜索..." onChange={onSearch} size="small" />
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }}>
|
||||
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<Tree
|
||||
showIcon
|
||||
loadData={onLoadData}
|
||||
@@ -648,11 +673,26 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
titleRender={titleRender}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={onExpand}
|
||||
loadedKeys={loadedKeys}
|
||||
onLoad={setLoadedKeys}
|
||||
autoExpandParent={autoExpandParent}
|
||||
blockNode
|
||||
height={treeHeight}
|
||||
onRightClick={onRightClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<Dropdown
|
||||
menu={{ items: contextMenu.items }}
|
||||
open={true}
|
||||
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<div style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, width: 1, height: 1 }} />
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="新建数据库"
|
||||
open={isCreateDbModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user