feat(connection): 增强连接管理与交互体验

- 新增测试连接功能,修复底层驱动假成功问题,确保密码/端口验证准确
- 支持导入/导出连接配置(JSON),便于迁移与备份
- 优化侧边栏:实现虚拟滚动解决卡顿,增加数据库筛选与断开连接重连机制
- 优化交互:改进右键菜单体验(全行触发/禁用选文),完善新建查询的上下文自动关联
- 界面调整:精简连接弹窗,移除冗余的默认数据库输入
This commit is contained in:
杨国锋
2026-02-02 16:33:11 +08:00
parent 7f201f9bcd
commit 4099796c88
15 changed files with 382 additions and 121 deletions

View File

@@ -62,17 +62,18 @@ jobs:
# Find .app bundle
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
if [ -z "$APP_NAME" ]; then
echo "❌ 未找到 .app 应用包!"
exit 1
fi
# Get pure name (e.g. GoNavi.app)
APP_NAME=$(basename "$APP_PATH")
# Ad-hoc codesign to prevent "Damaged" error (requires user to allow anyway, but valid structure)
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --options runtime --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.artifact_name }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME (源应用: $APP_NAME)..."
echo "📦 正在生成 DMG: $DMG_NAME..."
# Create DMG
create-dmg \

View File

@@ -65,7 +65,7 @@
```bash
# 克隆项目
git clone https://github.com/yangguofeng/GoNavi.git
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发服务器 (支持热重载)
@@ -93,6 +93,22 @@ wails build -clean
---
## ❓ 常见问题 (Troubleshooting)
### macOS 提示 "应用已损坏,无法打开"
由于本项目尚未购买 Apple 开发者证书进行签名NotarizationmacOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
2. 打开 **终端 (Terminal)**
3. 复制并执行以下命令(输入密码时不会显示):
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
---
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request

View File

@@ -13,8 +13,15 @@ html, body, #root {
.ant-tree .ant-tree-node-content-wrapper {
display: flex !important;
align-items: center;
overflow: hidden;
white-space: nowrap;
user-select: none !important;
-webkit-user-select: none !important;
}
.ant-tree .ant-tree-title,
.ant-tree .ant-tree-treenode * {
user-select: none !important;
-webkit-user-select: none !important;
}
.ant-tree .ant-tree-title {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme } from 'antd';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined } from '@ant-design/icons';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd';
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';
@@ -14,7 +14,82 @@ const { Sider, Content } = Layout;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const { darkMode, toggleDarkMode, addTab, activeContext } = useStore();
const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore();
const handleNewQuery = () => {
let connId = activeContext?.connectionId || '';
let db = activeContext?.dbName || '';
// Priority: Active Tab Context > Sidebar Selection
if (activeTabId) {
const currentTab = tabs.find(t => t.id === activeTabId);
if (currentTab && currentTab.connectionId) {
connId = currentTab.connectionId;
db = currentTab.dbName || '';
}
}
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: connId,
dbName: db
});
};
const handleImportConnections = async () => {
const res = await (window as any).go.app.App.ImportConfigFile();
if (res.success) {
try {
const imported = JSON.parse(res.data);
if (Array.isArray(imported)) {
let count = 0;
imported.forEach((conn: any) => {
if (!connections.some(c => c.id === conn.id)) {
addConnection(conn);
count++;
}
});
message.success(`成功导入 ${count} 个连接`);
} else {
message.error("文件格式错误:需要 JSON 数组");
}
} catch (e) {
message.error("解析 JSON 失败");
}
} else if (res.message !== "Cancelled") {
message.error("导入失败: " + res.message);
}
};
const handleExportConnections = async () => {
if (connections.length === 0) {
message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, [], "connections", "json");
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
}
};
const settingsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
}
];
// Log Panel
const [logPanelHeight, setLogPanelHeight] = useState(200);
@@ -151,40 +226,37 @@ function App() {
width={sidebarWidth}
style={{
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
position: 'relative',
display: 'flex',
flexDirection: 'column'
position: 'relative'
}}
>
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
<div>
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={() => addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: activeContext?.connectionId || '',
dbName: activeContext?.dbName || ''
})} title="新建查询" />
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
<div>
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
<Dropdown menu={{ items: settingsMenu }} placement="bottomRight">
<Button type="text" icon={<SettingOutlined />} title="更多设置" />
</Dropdown>
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{/* Sidebar Footer for Log Toggle */}
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'center' }}>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
block
>
SQL
</Button>
{/* Sidebar Footer for Log Toggle */}
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
block
>
SQL
</Button>
</div>
</div>
{/* Sidebar Resize Handle */}

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ export interface SavedConnection {
id: string;
name: string;
config: ConnectionConfig;
includeDatabases?: string[];
}
export interface ColumnDefinition {

View File

@@ -30,6 +30,8 @@ export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,ar
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -43,3 +45,5 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function OpenSQLFile():Promise<connection.QueryResult>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -58,6 +58,10 @@ export function ExportTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
export function ImportData(arg1, arg2, arg3) {
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
}
@@ -85,3 +89,7 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -40,7 +40,16 @@ func (a *App) Shutdown(ctx context.Context) {
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
return fmt.Sprintf("%s|%s|%s:%d|%s|%s|%v", config.Type, config.User, config.Host, config.Port, config.Database, config.SSH.Host, config.UseSSH)
sshPart := ""
if config.UseSSH {
sshPart = fmt.Sprintf("|ssh:%s@%s:%d|%s", config.SSH.User, config.SSH.Host, config.SSH.Port, config.SSH.KeyPath)
// We don't include SSH password in key string to avoid log exposure if key is logged,
// but for cache uniqueness it is critical.
// Let's include a hash or just the value if we assume internal use.
// Including value for correctness.
sshPart += "|" + config.SSH.Password
}
return fmt.Sprintf("%s|%s:%s@%s:%d|%s%s", config.Type, config.User, config.Password, config.Host, config.Port, config.Database, sshPart)
}
// Helper: Get or create a database connection

View File

@@ -28,7 +28,27 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Connected successfully"}
return connection.QueryResult{Success: true, Message: "连接成功"}
}
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
// Force close existing cached connection if any to ensure fresh test
key := getCacheKey(config)
func() {
a.mu.Lock()
defer a.mu.Unlock()
if oldDB, ok := a.dbCache[key]; ok {
oldDB.Close()
delete(a.dbCache, key)
}
}()
_, err := a.getDatabase(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "连接成功"}
}
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {

View File

@@ -44,6 +44,33 @@ func (a *App) OpenSQLFile() connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
func (a *App) ImportConfigFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Config File",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
content, err := os.ReadFile(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: string(content)}
}
func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: fmt.Sprintf("Import into %s", tableName),

View File

@@ -41,7 +41,9 @@ func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
return err
}
m.conn = db
return nil
// Force verification
return m.Ping()
}
func (m *MySQLDB) Close() error {

View File

@@ -40,7 +40,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
return err
}
p.conn = db
return nil
// Force verification
return p.Ping()
}
func (p *PostgresDB) Close() error {

View File

@@ -22,7 +22,9 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
return err
}
s.conn = db
return nil
// Force verification
return s.Ping()
}
func (s *SQLiteDB) Close() error {