mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-08 15:39:51 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af88e7ca44 | ||
|
|
9986bd98c1 | ||
|
|
9559291fa3 | ||
|
|
a1f2b4767d | ||
|
|
bd3b2f77dc | ||
|
|
f5e7e61439 | ||
|
|
75e3d6af8a | ||
|
|
4ac8522dab | ||
|
|
001d15fca3 | ||
|
|
fc1b2f684b | ||
|
|
1b3a3d26d8 | ||
|
|
9dbea2f93a | ||
|
|
442645ffb0 | ||
|
|
854ed5a8e3 | ||
|
|
250d01964d | ||
|
|
b51bb9a5a5 | ||
|
|
4099796c88 |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -70,9 +70,13 @@ jobs:
|
|||||||
# Get pure name (e.g. GoNavi.app)
|
# Get pure name (e.g. GoNavi.app)
|
||||||
APP_NAME=$(basename "$APP_PATH")
|
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"
|
DMG_NAME="${{ matrix.artifact_name }}.dmg"
|
||||||
|
|
||||||
echo "📦 正在生成 DMG: $DMG_NAME (源应用: $APP_NAME)..."
|
echo "📦 正在生成 DMG: $DMG_NAME..."
|
||||||
|
|
||||||
# Create DMG
|
# Create DMG
|
||||||
create-dmg \
|
create-dmg \
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -1,6 +1,10 @@
|
|||||||
# GoNavi - 现代化的轻量级数据库管理工具
|
# GoNavi - 现代化的轻量级数据库管理工具
|
||||||
|
<img width="1200" height="1116" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||||

|
<img width="4096" height="2180" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||||
|
<img width="4096" height="2304" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||||
|
<img width="4096" height="2304" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||||
|
<img width="4096" height="2304" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||||
|
<img width="4096" height="2180" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||||
|
|
||||||
[](https://go.dev/)
|
[](https://go.dev/)
|
||||||
[](https://wails.io)
|
[](https://wails.io)
|
||||||
@@ -65,7 +69,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
# 克隆项目
|
||||||
git clone https://github.com/yangguofeng/GoNavi.git
|
git clone https://github.com/Syngnat/GoNavi.git
|
||||||
cd GoNavi
|
cd GoNavi
|
||||||
|
|
||||||
# 启动开发服务器 (支持热重载)
|
# 启动开发服务器 (支持热重载)
|
||||||
@@ -93,6 +97,22 @@ wails build -clean
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ❓ 常见问题 (Troubleshooting)
|
||||||
|
|
||||||
|
### macOS 提示 "应用已损坏,无法打开"
|
||||||
|
|
||||||
|
由于本项目尚未购买 Apple 开发者证书进行签名(Notarization),macOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
|
||||||
|
|
||||||
|
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
|
||||||
|
2. 打开 **终端 (Terminal)**。
|
||||||
|
3. 复制并执行以下命令(输入密码时不会显示):
|
||||||
|
```bash
|
||||||
|
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||||
|
```
|
||||||
|
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🤝 贡献指南
|
## 🤝 贡献指南
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@types/react-resizable": "^3.0.8",
|
"@types/react-resizable": "^3.0.8",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8"
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ html, body, #root {
|
|||||||
.ant-tree .ant-tree-node-content-wrapper {
|
.ant-tree .ant-tree-node-content-wrapper {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
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 {
|
.ant-tree .ant-tree-title {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Layout, Button, ConfigProvider, theme } from 'antd';
|
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd';
|
||||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined } from '@ant-design/icons';
|
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined, SettingOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import TabManager from './components/TabManager';
|
import TabManager from './components/TabManager';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
import ConnectionModal from './components/ConnectionModal';
|
||||||
@@ -14,7 +14,82 @@ const { Sider, Content } = Layout;
|
|||||||
function App() {
|
function App() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
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
|
// Log Panel
|
||||||
const [logPanelHeight, setLogPanelHeight] = useState(200);
|
const [logPanelHeight, setLogPanelHeight] = useState(200);
|
||||||
@@ -151,40 +226,37 @@ function App() {
|
|||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
style={{
|
style={{
|
||||||
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||||
position: 'relative',
|
position: 'relative'
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||||
<div>
|
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
||||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
<div>
|
||||||
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={() => addTab({
|
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
||||||
id: `query-${Date.now()}`,
|
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
|
||||||
title: '新建查询',
|
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||||
type: 'query',
|
<Dropdown menu={{ items: settingsMenu }} placement="bottomRight">
|
||||||
connectionId: activeContext?.connectionId || '',
|
<Button type="text" icon={<SettingOutlined />} title="更多设置" />
|
||||||
dbName: activeContext?.dbName || ''
|
</Dropdown>
|
||||||
})} title="新建查询" />
|
</div>
|
||||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Sidebar onEditConnection={handleEditConnection} />
|
||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
</div>
|
||||||
<Sidebar onEditConnection={handleEditConnection} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar Footer for Log Toggle */}
|
{/* Sidebar Footer for Log Toggle */}
|
||||||
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<Button
|
<Button
|
||||||
type={isLogPanelOpen ? "primary" : "text"}
|
type={isLogPanelOpen ? "primary" : "text"}
|
||||||
icon={<BugOutlined />}
|
icon={<BugOutlined />}
|
||||||
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
SQL 执行日志
|
SQL 执行日志
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Resize Handle */}
|
{/* Sidebar Resize Handle */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useStore } from '../store';
|
||||||
import { MySQLConnect } from '../../wailsjs/go/app/App';
|
import { MySQLConnect, MySQLGetDatabases } from '../../wailsjs/go/app/App';
|
||||||
import { SavedConnection } from '../types';
|
import { SavedConnection } from '../types';
|
||||||
|
|
||||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
|
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 [loading, setLoading] = useState(false);
|
||||||
const [useSSH, setUseSSH] = useState(false);
|
const [useSSH, setUseSSH] = useState(false);
|
||||||
const [dbType, setDbType] = useState('mysql');
|
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 addConnection = useStore((state) => state.addConnection);
|
||||||
const updateConnection = useStore((state) => state.updateConnection);
|
const updateConnection = useStore((state) => state.updateConnection);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setTestResult(null); // Reset test result
|
||||||
|
setDbList([]);
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
type: initialValues.config.type,
|
type: initialValues.config.type,
|
||||||
@@ -23,6 +27,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
user: initialValues.config.user,
|
user: initialValues.config.user,
|
||||||
password: initialValues.config.password,
|
password: initialValues.config.password,
|
||||||
database: initialValues.config.database,
|
database: initialValues.config.database,
|
||||||
|
includeDatabases: initialValues.includeDatabases,
|
||||||
useSSH: initialValues.config.useSSH,
|
useSSH: initialValues.config.useSSH,
|
||||||
sshHost: initialValues.config.ssh?.host,
|
sshHost: initialValues.config.ssh?.host,
|
||||||
sshPort: initialValues.config.ssh?.port,
|
sshPort: initialValues.config.ssh?.port,
|
||||||
@@ -45,25 +50,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const sshConfig = values.useSSH ? {
|
const config = await buildConfig(values);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Use Connect to verify before saving
|
||||||
const res = await MySQLConnect(config as any);
|
const res = await MySQLConnect(config as any);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@@ -71,7 +60,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const newConn = {
|
const newConn = {
|
||||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
|
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
|
||||||
config: config
|
config: config,
|
||||||
|
includeDatabases: values.includeDatabases
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initialValues) {
|
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';
|
const isSqlite = dbType === 'sqlite';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,8 +138,11 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
okText="确定"
|
footer={[
|
||||||
cancelText="取消"
|
<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}
|
width={600}
|
||||||
zIndex={10001} // Increase z-index
|
zIndex={10001} // Increase z-index
|
||||||
destroyOnHidden // Reset on close
|
destroyOnHidden // Reset on close
|
||||||
@@ -115,6 +153,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{ type: 'mysql', host: 'localhost', port: 3306, user: 'root', useSSH: false, sshPort: 22 }}
|
initialValues={{ type: 'mysql', host: 'localhost', port: 3306, user: 'root', useSSH: false, sshPort: 22 }}
|
||||||
onValuesChange={(changed) => {
|
onValuesChange={(changed) => {
|
||||||
|
if (testResult) setTestResult(null); // Clear result on change
|
||||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||||
if (changed.type !== undefined) setDbType(changed.type);
|
if (changed.type !== undefined) setDbType(changed.type);
|
||||||
}}
|
}}
|
||||||
@@ -155,8 +194,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSqlite && (
|
{!isSqlite && (
|
||||||
<Form.Item name="database" label="默认数据库 (可选)">
|
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||||
<Input />
|
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
|
||||||
|
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
|
||||||
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -193,6 +234,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<Alert
|
||||||
|
message={testResult.message}
|
||||||
|
type={testResult.type}
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutli
|
|||||||
import { Resizable } from 'react-resizable';
|
import { Resizable } from 'react-resizable';
|
||||||
import { ImportData, ExportTable, ExportData, ApplyChanges } from '../../wailsjs/go/app/App';
|
import { ImportData, ExportTable, ExportData, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
|
|
||||||
// --- Helper: Format Value ---
|
// --- Helper: Format Value ---
|
||||||
@@ -214,6 +215,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const addSqlLog = useStore(state => state.addSqlLog);
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
||||||
|
|
||||||
// Helper to export specific data
|
// Helper to export specific data
|
||||||
const exportData = async (rows: any[], format: string) => {
|
const exportData = async (rows: any[], format: string) => {
|
||||||
@@ -234,16 +236,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
let rafId: number;
|
||||||
const resizeObserver = new ResizeObserver(entries => {
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
for (let entry of entries) {
|
rafId = requestAnimationFrame(() => {
|
||||||
// Subtract header height (~40px)
|
for (let entry of entries) {
|
||||||
// Ensure minimum height to prevent collapse loop
|
// Use boundingClientRect for more accurate render size (including padding if any)
|
||||||
const h = Math.max(100, entry.contentRect.height - 42);
|
const height = entry.contentRect.height;
|
||||||
setTableHeight(h);
|
if (height < 50) return;
|
||||||
}
|
// Subtract header (~42px) and a buffer
|
||||||
|
const h = Math.max(100, height - 42);
|
||||||
|
setTableHeight(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
return () => resizeObserver.disconnect();
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
@@ -685,7 +697,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
<div className={gridId} style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
|
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
|
||||||
@@ -747,7 +759,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0 }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Form component={false} form={form}>
|
<Form component={false} form={form}>
|
||||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||||
@@ -794,9 +806,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.row-added td { background-color: #f6ffed !important; }
|
.${gridId} .row-added td { background-color: #f6ffed !important; }
|
||||||
.row-modified td { background-color: #e6f7ff !important; }
|
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
|
||||||
.ant-table-body {
|
.${gridId} .ant-table-body {
|
||||||
height: ${tableHeight}px !important;
|
height: ${tableHeight}px !important;
|
||||||
max-height: ${tableHeight}px !important;
|
max-height: ${tableHeight}px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ height: '100%', width: '100%', overflow: 'hidden' }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
data={data}
|
data={data}
|
||||||
columnNames={columnNames}
|
columnNames={columnNames}
|
||||||
@@ -178,6 +179,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
onToggleFilter={handleToggleFilter}
|
onToggleFilter={handleToggleFilter}
|
||||||
onApplyFilter={handleApplyFilter}
|
onApplyFilter={handleApplyFilter}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
|
||||||
import {
|
import {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
@@ -46,6 +46,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
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'
|
// Connection Status State: key -> 'success' | 'error'
|
||||||
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
|
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
|
||||||
@@ -87,7 +104,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
})));
|
})));
|
||||||
}, [connections]);
|
}, [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 => {
|
return list.map(node => {
|
||||||
if (node.key === key) {
|
if (node.key === key) {
|
||||||
return { ...node, children };
|
return { ...node, children };
|
||||||
@@ -112,7 +129,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const res = await MySQLGetDatabases(config as any);
|
const res = await MySQLGetDatabases(config as any);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setConnectionStates(prev => ({ ...prev, [conn.id]: '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,
|
title: row.Database || row.database,
|
||||||
key: `${conn.id}-${row.Database || row.database}`,
|
key: `${conn.id}-${row.Database || row.database}`,
|
||||||
icon: <DatabaseOutlined />,
|
icon: <DatabaseOutlined />,
|
||||||
@@ -120,6 +137,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
dataRef: { ...conn, dbName: row.Database || row.database },
|
dataRef: { ...conn, dbName: row.Database || row.database },
|
||||||
isLeaf: false,
|
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));
|
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||||
} else {
|
} else {
|
||||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||||
@@ -433,27 +456,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
return loop(treeData);
|
return loop(treeData);
|
||||||
}, [searchValue, treeData]);
|
}, [searchValue, treeData]);
|
||||||
|
|
||||||
const titleRender = (node: any) => {
|
const getNodeMenuItems = (node: any): MenuProps['items'] => {
|
||||||
// Determine status
|
|
||||||
let status: 'success' | 'error' | 'default' = 'default';
|
|
||||||
if (node.type === 'connection') {
|
if (node.type === 'connection') {
|
||||||
if (connectionStates[node.key] === 'success') status = 'success';
|
return [
|
||||||
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'] = [
|
|
||||||
{
|
{
|
||||||
key: 'new-db',
|
key: 'new-db',
|
||||||
label: '新建数据库',
|
label: '新建数据库',
|
||||||
@@ -498,16 +503,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
label: '断开连接',
|
label: '断开连接',
|
||||||
icon: <DisconnectOutlined />,
|
icon: <DisconnectOutlined />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
// Reset status
|
// Reset status recursively
|
||||||
setConnectionStates(prev => {
|
setConnectionStates(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[node.key];
|
Object.keys(next).forEach(k => {
|
||||||
|
if (k === node.key || k.startsWith(`${node.key}-`)) {
|
||||||
|
delete next[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Collapse node
|
// Collapse node and children
|
||||||
setExpandedKeys(prev => prev.filter(k => k !== node.key));
|
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||||
// Clear children
|
// Reset loaded state recursively
|
||||||
setTreeData(origin => updateTreeData(origin, node.key, []));
|
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||||
|
// Clear children (undefined to trigger reload)
|
||||||
|
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||||
message.success("已断开连接");
|
message.success("已断开连接");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -525,13 +536,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') {
|
} else if (node.type === 'database') {
|
||||||
const items: MenuProps['items'] = [
|
return [
|
||||||
{
|
{
|
||||||
key: 'new-table',
|
key: 'new-table',
|
||||||
label: '新建表',
|
label: '新建表',
|
||||||
@@ -550,16 +556,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
label: '关闭数据库',
|
label: '关闭数据库',
|
||||||
icon: <DisconnectOutlined />,
|
icon: <DisconnectOutlined />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
// Reset status
|
|
||||||
setConnectionStates(prev => {
|
setConnectionStates(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[node.key];
|
delete next[node.key];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Collapse node
|
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||||
setExpandedKeys(prev => prev.filter(k => k !== node.key));
|
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||||
// Clear children
|
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||||
setTreeData(origin => updateTreeData(origin, node.key, []));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -583,13 +587,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
onClick: () => handleRunSQLFile(node)
|
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') {
|
} 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',
|
key: 'design-table',
|
||||||
label: '设计表',
|
label: '设计表',
|
||||||
@@ -623,14 +637,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 (
|
return (
|
||||||
@@ -638,7 +671,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
<div style={{ padding: '4px 8px' }}>
|
<div style={{ padding: '4px 8px' }}>
|
||||||
<Search placeholder="搜索..." onChange={onSearch} size="small" />
|
<Search placeholder="搜索..." onChange={onSearch} size="small" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }}>
|
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<Tree
|
<Tree
|
||||||
showIcon
|
showIcon
|
||||||
loadData={onLoadData}
|
loadData={onLoadData}
|
||||||
@@ -648,11 +681,26 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
titleRender={titleRender}
|
titleRender={titleRender}
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
|
loadedKeys={loadedKeys}
|
||||||
|
onLoad={setLoadedKeys}
|
||||||
autoExpandParent={autoExpandParent}
|
autoExpandParent={autoExpandParent}
|
||||||
blockNode
|
blockNode
|
||||||
|
height={treeHeight}
|
||||||
|
onRightClick={onRightClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
title="新建数据库"
|
title="新建数据库"
|
||||||
open={isCreateDbModalOpen}
|
open={isCreateDbModalOpen}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface SavedConnection {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
config: ConnectionConfig;
|
config: ConnectionConfig;
|
||||||
|
includeDatabases?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnDefinition {
|
export interface ColumnDefinition {
|
||||||
|
|||||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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 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 ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function MySQLConnect(arg1:connection.ConnectionConfig):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 MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export function ExportTable(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['app']['App']['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) {
|
export function ImportData(arg1, arg2, arg3) {
|
||||||
return window['go']['app']['App']['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() {
|
export function OpenSQLFile() {
|
||||||
return window['go']['app']['App']['OpenSQLFile']();
|
return window['go']['app']['App']['OpenSQLFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestConnection(arg1) {
|
||||||
|
return window['go']['app']['App']['TestConnection'](arg1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,16 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
|
|
||||||
// Helper: Generate a unique key for the connection config
|
// Helper: Generate a unique key for the connection config
|
||||||
func getCacheKey(config connection.ConnectionConfig) string {
|
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
|
// Helper: Get or create a database connection
|
||||||
|
|||||||
@@ -10,25 +10,22 @@ import (
|
|||||||
// Generic DB Methods
|
// Generic DB Methods
|
||||||
|
|
||||||
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
key := getCacheKey(config)
|
// getDatabase checks cache and Pings. If valid, reuses. If not, connects.
|
||||||
|
|
||||||
// Use an anonymous function to scope the lock
|
|
||||||
func() {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
if oldDB, ok := a.dbCache[key]; ok {
|
|
||||||
oldDB.Close()
|
|
||||||
delete(a.dbCache, key)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// getDatabase acquires the lock internally, so we must be unlocked here
|
|
||||||
_, err := a.getDatabase(config)
|
_, err := a.getDatabase(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
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 {
|
||||||
|
_, 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 {
|
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ func (a *App) OpenSQLFile() connection.QueryResult {
|
|||||||
return connection.QueryResult{Success: true, Data: string(content)}
|
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 {
|
func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult {
|
||||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||||
Title: fmt.Sprintf("Import into %s", tableName),
|
Title: fmt.Sprintf("Import into %s", tableName),
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.conn = db
|
m.conn = db
|
||||||
return nil
|
|
||||||
|
// Force verification
|
||||||
|
return m.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) Close() error {
|
func (m *MySQLDB) Close() error {
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.conn = db
|
p.conn = db
|
||||||
return nil
|
|
||||||
|
// Force verification
|
||||||
|
return p.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) Close() error {
|
func (p *PostgresDB) Close() error {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.conn = db
|
s.conn = db
|
||||||
return nil
|
|
||||||
|
// Force verification
|
||||||
|
return s.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) Close() error {
|
func (s *SQLiteDB) Close() error {
|
||||||
|
|||||||
Reference in New Issue
Block a user