Merge pull request #7 from Syngnat/feature/init-20260202-ygf

-  新增导出选中数据/当前页功能,支持 CSV/JSON/MD 格式
-  实现导出文件名默认使用表名
-  在侧边栏增加连接状态指示灯(红/绿/灰)及断开连接功能
-  优化数据表格点击交互,改为双击编辑防止误触
-  修复侧边栏滚动条显示及内容截断问题
-  修复 SQL 调试日志及导出时的上下文引用错误
This commit is contained in:
Syngnat
2026-02-02 14:39:51 +08:00
committed by GitHub
14 changed files with 831 additions and 68 deletions

View File

@@ -51,6 +51,9 @@ jobs:
- name: Package macOS Application
if: contains(matrix.platform, 'darwin')
run: |
# Install create-dmg
brew install create-dmg
cd build/bin
echo "📂 列出 build/bin 目录内容:"
ls -F
@@ -63,8 +66,24 @@ jobs:
exit 1
fi
echo "📦 正在压缩 $APP_NAME..."
zip -r "../../${{ matrix.artifact_name }}.zip" "$APP_NAME"
DMG_NAME="${{ matrix.artifact_name }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME..."
# Create DMG
create-dmg \
--volname "GoNavi Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
# Move DMG to root for upload
mv "$DMG_NAME" "../../$DMG_NAME"
- name: Package Windows Executable
if: contains(matrix.platform, 'windows')
@@ -96,7 +115,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
${{ matrix.artifact_name }}.zip
${{ matrix.artifact_name }}.dmg
${{ matrix.artifact_name }}.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# GoNavi - 现代化的轻量级数据库管理工具
![GoNavi Banner](https://socialify.git.ci/yangguofeng/GoNavi/image?description=1&font=Inter&language=1&name=1&owner=1&pattern=Circuit%20Board&theme=Auto)
[![Go Version](https://img.shields.io/github/go-mod/go-version/yangguofeng/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/yangguofeng/GoNavi/release.yml?label=Build)](https://github.com/yangguofeng/GoNavi/actions)
**GoNavi** 是一款基于 **Wails (Go)****React** 构建的现代化、高性能、跨平台数据库管理客户端。它旨在提供如原生应用般流畅的用户体验,同时保持极低的资源占用。
相比于 Electron 应用GoNavi 的体积更小(~10MB启动速度更快内存占用更低。
---
## ✨ 核心特性
### 🚀 极致性能
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
### 🔌 多数据库支持
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
- **PostgreSQL**:基础支持(持续完善中)。
- **SQLite**:本地文件数据库支持。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
### 📊 强大的数据管理 (DataGrid)
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
### 📝 智能 SQL 编辑器
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
### 🎨 现代化 UI
- **Ant Design 5**:企业级 UI 设计语言。
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
- **响应式布局**:灵活的侧边栏与布局调整。
---
## 🛠️ 技术栈
* **后端 (Backend)**: Go 1.24 + Wails v2
* **前端 (Frontend)**: React 18 + TypeScript + Vite
* **UI 框架**: Ant Design 5
* **状态管理**: Zustand
* **编辑器**: Monaco Editor
---
## 📦 安装与运行
### 前置要求
* [Go](https://go.dev/dl/) 1.21+
* [Node.js](https://nodejs.org/) 18+
* [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### 开发模式
```bash
# 克隆项目
git clone https://github.com/yangguofeng/GoNavi.git
cd GoNavi
# 启动开发服务器 (支持热重载)
wails dev
```
### 编译构建
```bash
# 构建当前平台的可执行文件
wails build
# 清理并构建 (推荐发布前使用)
wails build -clean
```
构建产物将位于 `build/bin` 目录下。
### 跨平台编译 (GitHub Actions)
本项目内置了 GitHub Actions 流水线Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
支持构建:
* macOS (AMD64 / ARM64)
* Windows (AMD64)
---
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 本仓库
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启一个 Pull Request
## 📄 开源协议
本项目采用 [Apache-2.0 协议](LICENSE) 开源。

View File

@@ -1,17 +1,73 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme } from 'antd';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined } from '@ant-design/icons';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined } from '@ant-design/icons';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import './App.css';
const { Sider, Content } = Layout;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const { darkMode, toggleDarkMode, addTab, activeContext } = useStore();
// Log Panel
const [logPanelHeight, setLogPanelHeight] = useState(200);
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const logResizeRef = React.useRef<{ startY: number, startHeight: number } | null>(null);
const logGhostRef = React.useRef<HTMLDivElement>(null);
const handleLogResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
logResizeRef.current = { startY: e.clientY, startHeight: logPanelHeight };
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
logGhostRef.current.style.display = 'block';
}
document.addEventListener('mousemove', handleLogResizeMove);
document.addEventListener('mouseup', handleLogResizeUp);
};
const handleLogResizeMove = (e: MouseEvent) => {
if (!logResizeRef.current) return;
// Just update ghost line, no state update
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
}
};
const handleLogResizeUp = (e: MouseEvent) => {
if (logResizeRef.current) {
const delta = logResizeRef.current.startY - e.clientY;
const newHeight = Math.max(100, Math.min(800, logResizeRef.current.startHeight + delta));
setLogPanelHeight(newHeight);
}
if (logGhostRef.current) {
logGhostRef.current.style.display = 'none';
}
logResizeRef.current = null;
document.removeEventListener('mousemove', handleLogResizeMove);
document.removeEventListener('mouseup', handleLogResizeUp);
};
const handleEditConnection = (conn: SavedConnection) => {
setEditingConnection(conn);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingConnection(null);
};
// Sidebar Resizing
const [sidebarWidth, setSidebarWidth] = useState(300);
@@ -95,10 +151,12 @@ function App() {
width={sidebarWidth}
style={{
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' }}>
<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="切换主题" />
@@ -112,7 +170,22 @@ function App() {
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
</div>
</div>
<Sidebar />
<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>
</div>
{/* Sidebar Resize Handle */}
<div
@@ -130,12 +203,25 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden' }}>
<TabManager />
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
/>
)}
</Content>
<ConnectionModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
initialValues={editingConnection}
/>
{/* Ghost Resize Line */}
{/* Ghost Resize Line for Sidebar */}
<div
ref={ghostRef}
style={{
@@ -150,6 +236,22 @@ function App() {
display: 'none'
}}
/>
{/* Ghost Resize Line for Log Panel */}
<div
ref={logGhostRef}
style={{
position: 'fixed',
left: sidebarWidth, // Start from sidebar edge
right: 0,
height: '4px',
background: 'rgba(24, 144, 255, 0.5)',
zIndex: 9999,
pointerEvents: 'none',
display: 'none',
cursor: 'row-resize'
}}
/>
</Layout>
</ConfigProvider>
);

View File

@@ -1,14 +1,44 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
import { useStore } from '../store';
import { MySQLConnect } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [dbType, setDbType] = useState('mysql');
const addConnection = useStore((state) => state.addConnection);
const updateConnection = useStore((state) => state.updateConnection);
useEffect(() => {
if (open) {
if (initialValues) {
form.setFieldsValue({
type: initialValues.config.type,
name: initialValues.name,
host: initialValues.config.host,
port: initialValues.config.port,
user: initialValues.config.user,
password: initialValues.config.password,
database: initialValues.config.database,
useSSH: initialValues.config.useSSH,
sshHost: initialValues.config.ssh?.host,
sshPort: initialValues.config.ssh?.port,
sshUser: initialValues.config.ssh?.user,
sshPassword: initialValues.config.ssh?.password,
sshKeyPath: initialValues.config.ssh?.keyPath,
});
setUseSSH(initialValues.config.useSSH || false);
setDbType(initialValues.config.type);
} else {
form.resetFields();
setUseSSH(false);
setDbType('mysql');
}
}
}, [open, initialValues]);
const handleOk = async () => {
try {
@@ -38,12 +68,20 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ ope
setLoading(false);
if (res.success) {
addConnection({
id: Date.now().toString(),
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
config: config
});
message.success('连接已保存!');
};
if (initialValues) {
updateConnection(newConn);
message.success('连接已更新!');
} else {
addConnection(newConn);
message.success('连接已保存!');
}
form.resetFields();
setUseSSH(false);
setDbType('mysql');
@@ -60,7 +98,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ ope
return (
<Modal
title="新建连接"
title={initialValues ? "编辑连接" : "新建连接"}
open={open}
onCancel={onClose}
onOk={handleOk}
@@ -69,7 +107,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ ope
cancelText="取消"
width={600}
zIndex={10001} // Increase z-index
destroyOnClose // Reset on close
destroyOnHidden // Reset on close
maskClosable={false} // Prevent accidental close by clicking mask, user must click X or Cancel
>
<Form

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select } from 'antd';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined } from '@ant-design/icons';
import { Resizable } from 'react-resizable';
import { ImportData, ExportTable, ApplyChanges } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ApplyChanges } from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import 'react-resizable/css/styles.css';
@@ -61,6 +61,7 @@ const DataContext = React.createContext<{
handleCopyInsert: (r: any) => void;
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
handleExportSelected: (format: string, r: any) => void;
copyToClipboard: (t: string) => void;
tableName?: string;
} | null>(null);
@@ -123,13 +124,13 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
</Form.Item>
) : (
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }}>
{children}
</div>
);
}
return <td {...restProps}>{childNode}</td>;
return <td {...restProps} onDoubleClick={editable ? toggleEdit : undefined}>{childNode}</td>;
});
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
@@ -165,6 +166,18 @@ const ContextMenuRow = React.memo(({ children, ...props }: any) => {
});
copyToClipboard(lines.join('\n'));
} },
{ type: 'divider' },
{
key: 'export-selected',
label: '导出选中数据',
icon: <ExportOutlined />,
children: [
{ key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected('csv', record) },
{ key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected('xlsx', record) },
{ key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected('json', record) },
{ key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected('md', record) },
]
}
];
return (
@@ -197,8 +210,20 @@ const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
const { connections } = useStore();
const addSqlLog = useStore(state => state.addSqlLog);
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
// Helper to export specific data
const exportData = async (rows: any[], format: string) => {
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
const cleanRows = rows.map(({ key, ...rest }) => rest);
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, columnNames, tableName || 'export', format);
hide();
if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); }
};
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
@@ -483,14 +508,41 @@ const DataGrid: React.FC<DataGridProps> = ({
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const startTime = Date.now();
const res = await ApplyChanges(config as any, dbName || '', tableName, { inserts, updates, deletes } as any);
const duration = Date.now() - startTime;
// Construct a pseudo-SQL representation for the log
let logSql = `/* Batch Apply on ${tableName} */\n`;
if (inserts.length > 0) logSql += `INSERT ${inserts.length} rows;\n`;
if (updates.length > 0) logSql += `UPDATE ${updates.length} rows;\n`;
if (deletes.length > 0) logSql += `DELETE ${deletes.length} rows;\n`;
if (res.success) {
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql.trim(),
status: 'success',
duration,
message: res.message,
dbName
});
message.success("Changes committed successfully!");
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
if (onReload) onReload();
} else {
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql.trim(),
status: 'error',
duration,
message: res.message,
dbName
});
message.error("Commit failed: " + res.message);
}
};
@@ -540,17 +592,57 @@ const DataGrid: React.FC<DataGridProps> = ({
copyToClipboard(lines.join('\n'));
}, [getTargets, copyToClipboard]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
const records = getTargets(record);
await exportData(records, format);
}, [getTargets]);
// Export
const handleExport = async (format: string) => {
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, dbName || '', tableName, format);
hide();
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
// 1. Export Selected
if (selectedRowKeys.length > 0) {
const selectedRows = displayData.filter(d => selectedRowKeys.includes(d.key));
await exportData(selectedRows, format);
return;
}
// 2. Prompt for Current vs All
// Using a custom modal content with buttons to handle 3 states
let instance: any;
const handleAll = async () => {
instance.destroy();
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
const hide = message.loading(`正在导出全部数据...`, 0);
const res = await ExportTable(config as any, dbName || '', tableName, format);
hide();
if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); }
};
const handlePage = async () => {
instance.destroy();
await exportData(displayData, format);
};
instance = modal.info({
title: '导出选项',
content: (
<div>
<p></p>
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<Button onClick={() => instance.destroy()}></Button>
<Button onClick={handlePage}> ({displayData.length})</Button>
<Button type="primary" onClick={handleAll}></Button>
</div>
</div>
),
icon: <ExportOutlined />,
okButtonProps: { style: { display: 'none' } }, // Hide default OK
maskClosable: true,
});
};
const handleImport = async () => {
@@ -645,15 +737,20 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
))}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
<Button type="primary" onClick={applyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={() => {
setFilterConditions([]);
if (onApplyFilter) onApplyFilter([]);
}}></Button>
</div>
</div>
)}
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{contextHolder}
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard, tableName }}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}

View File

@@ -10,7 +10,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const connections = useStore(state => state.connections);
const { connections, addSqlLog } = useStore();
const [pagination, setPagination] = useState({
current: 1,
@@ -65,6 +65,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const offset = (page - 1) * size;
sql += ` LIMIT ${size} OFFSET ${offset}`;
const startTime = Date.now();
try {
const pCount = MySQLQuery(config as any, dbName, countSql);
const pData = MySQLQuery(config as any, dbName, sql);
@@ -75,6 +76,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
const [resCount, resData] = await Promise.all([pCount, pData]);
const duration = Date.now() - startTime;
// Log Execution
addSqlLog({
id: `log-${Date.now()}-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount.success ? 'success' : 'error',
duration: duration / 2, // Estimate
message: resCount.success ? '' : resCount.message,
dbName
});
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: sql,
status: resData.success ? 'success' : 'error',
duration: duration,
message: resData.success ? '' : resData.message,
affectedRows: Array.isArray(resData.data) ? resData.data.length : undefined,
dbName
});
if (pCols) {
const resCols = await pCols;
@@ -107,6 +131,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
} catch (e: any) {
message.error("Error fetching data: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: sql,
status: 'error',
duration: Date.now() - startTime,
message: e.message,
dbName
});
}
setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);

View File

@@ -0,0 +1,114 @@
import React, { useRef, useEffect } from 'react';
import { Table, Tag, Button, Tooltip } from 'antd';
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
import { useStore } from '../store';
interface LogPanelProps {
height: number;
onClose: () => void;
onResizeStart: (e: React.MouseEvent) => void;
}
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
const { sqlLogs, clearSqlLogs, darkMode } = useStore();
const columns = [
{
title: 'Time',
dataIndex: 'timestamp',
width: 80,
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
},
{
title: 'Status',
dataIndex: 'status',
width: 70,
render: (status: string) => (
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
{status === 'success' ? 'OK' : 'ERR'}
</Tag>
)
},
{
title: 'Duration',
dataIndex: 'duration',
width: 70,
render: (d: number) => <span style={{ color: d > 1000 ? 'orange' : 'inherit', fontSize: '12px' }}>{d}ms</span>
},
{
title: 'SQL / Message',
dataIndex: 'sql',
render: (text: string, record: any) => (
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
</div>
)
}
];
return (
<div style={{
height,
borderTop: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
background: darkMode ? '#1f1f1f' : '#fff',
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 100 // Ensure above other content
}}>
{/* Resize Handle */}
<div
onMouseDown={onResizeStart}
style={{
position: 'absolute',
top: -4,
left: 0,
right: 0,
height: 8,
cursor: 'row-resize',
zIndex: 10
}}
/>
{/* Toolbar */}
<div style={{
padding: '4px 8px',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: darkMode ? '#2a2a2a' : '#fafafa',
height: 32
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
<BugOutlined /> SQL
</div>
<div>
<Tooltip title="清空日志">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
</Tooltip>
</div>
</div>
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
// scroll={{ y: height - 32 }} // Let flex handle it
/>
</div>
</div>
);
};
export default LogPanel;

View File

@@ -34,7 +34,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
const connections = useStore(state => state.connections);
const { connections, addSqlLog } = useStore();
const saveQuery = useStore(state => state.saveQuery);
const darkMode = useStore(state => state.darkMode);
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
@@ -250,27 +250,53 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setTargetTableName(simpleTableName);
setPkColumns(primaryKeys);
const res = await MySQLQuery(config as any, currentDb, query);
const startTime = Date.now();
try {
const res = await MySQLQuery(config as any, currentDb, query);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query`,
timestamp: Date.now(),
sql: query,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumnNames([]);
}
} else {
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumnNames([]);
message.error(res.message);
}
} else {
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.error(res.message);
} catch (e: any) {
message.error("Error executing query: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: query,
status: 'error',
duration: Date.now() - startTime,
message: e.message,
dbName: currentDb
});
}
setLoading(false);
};

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form } from 'antd';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
@@ -20,7 +20,9 @@ import {
LinkOutlined,
FileAddOutlined,
PlusOutlined,
ReloadOutlined
ReloadOutlined,
DeleteOutlined,
DisconnectOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
@@ -38,13 +40,16 @@ interface TreeNode {
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers';
}
const Sidebar: React.FC = () => {
const { connections, savedQueries, addTab, setActiveContext } = useStore();
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const { connections, savedQueries, addTab, setActiveContext, removeConnection } = useStore();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
// Connection Status State: key -> 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
// Create Database Modal
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
const [createDbForm] = Form.useForm();
@@ -106,6 +111,7 @@ const Sidebar: React.FC = () => {
};
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) => ({
title: row.Database || row.database,
key: `${conn.id}-${row.Database || row.database}`,
@@ -116,6 +122,7 @@ const Sidebar: React.FC = () => {
}));
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error(res.message);
}
};
@@ -153,6 +160,7 @@ const Sidebar: React.FC = () => {
};
const res = await MySQLGetTables(config as any, conn.dbName);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
const tables = (res.data as any[]).map((row: any) => {
const tableName = Object.values(row)[0] as string;
return {
@@ -167,6 +175,7 @@ const Sidebar: React.FC = () => {
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error(res.message);
}
};
@@ -425,6 +434,24 @@ const Sidebar: React.FC = () => {
}, [searchValue, treeData]);
const titleRender = (node: any) => {
// Determine status
let status: 'success' | 'error' | 'default' = 'default';
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'] = [
{
@@ -457,10 +484,50 @@ const Sidebar: React.FC = () => {
});
}
},
{ type: 'divider' },
{
key: 'edit',
label: '编辑连接',
icon: <EditOutlined />,
onClick: () => {
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'disconnect',
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, []));
message.success("已断开连接");
}
},
{
key: 'delete',
label: '删除连接',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
});
}
}
];
return (
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
<span title={node.title}>{node.title}</span>
<span title={node.title}>{statusBadge}{node.title}</span>
</Dropdown>
);
} else if (node.type === 'database') {
@@ -478,6 +545,23 @@ const Sidebar: React.FC = () => {
onClick: () => loadTables(node)
},
{ type: 'divider' },
{
key: 'disconnect-db',
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, []));
}
},
{
key: 'new-query',
label: '新建查询',
@@ -501,7 +585,7 @@ const Sidebar: React.FC = () => {
];
return (
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
<span title={node.title}>{node.title}</span>
<span title={node.title}>{statusBadge}{node.title}</span>
</Dropdown>
);
} else if (node.type === 'table') {
@@ -554,7 +638,7 @@ const Sidebar: React.FC = () => {
<div style={{ padding: '4px 8px' }}>
<Search placeholder="搜索..." onChange={onSearch} size="small" />
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }}>
<Tree
showIcon
loadData={onLoadData}
@@ -566,7 +650,6 @@ const Sidebar: React.FC = () => {
onExpand={onExpand}
autoExpandParent={autoExpandParent}
blockNode
height={500}
/>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useContext, useMemo } from 'react';
import React, { useEffect, useState, useContext, useMemo, useRef } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined } from '@ant-design/icons';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
@@ -166,6 +166,21 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore(state => state.connections);
const readOnly = !!tab.readOnly;
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const h = Math.max(200, entry.contentRect.height - 40);
setTableHeight(h);
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, [activeKey]); // Re-attach when tab switches
// --- Resizable Columns State ---
const [tableColumns, setTableColumns] = useState<any[]>([]);
@@ -531,7 +546,15 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
}),
}));
const columnsTabContent = readOnly ? (
const columnsTabContent = (
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
<style>{`
.table-designer-wrapper .ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>
{readOnly ? (
<Table
dataSource={columns}
columns={resizableColumns}
@@ -539,7 +562,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
size="small"
pagination={false}
loading={loading}
scroll={{ y: 'calc(100vh - 200px)' }}
scroll={{ y: tableHeight }}
bordered
components={{
header: {
@@ -557,7 +580,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
size="small"
pagination={false}
loading={loading}
scroll={{ y: 'calc(100vh - 200px)' }}
scroll={{ y: tableHeight }}
bordered
components={{
body: { row: SortableRow },
@@ -566,6 +589,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
/>
</SortableContext>
</DndContext>
)}
</div>
);
return (

View File

@@ -2,6 +2,17 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { SavedConnection, TabData, SavedQuery } from './types';
export interface SqlLog {
id: string;
timestamp: number;
sql: string;
status: 'success' | 'error';
duration: number;
message?: string;
dbName?: string;
affectedRows?: number;
}
interface AppState {
connections: SavedConnection[];
tabs: TabData[];
@@ -10,8 +21,10 @@ interface AppState {
savedQueries: SavedQuery[];
darkMode: boolean;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
sqlLogs: SqlLog[];
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
addTab: (tab: TabData) => void;
@@ -24,6 +37,9 @@ interface AppState {
toggleDarkMode: () => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
}
export const useStore = create<AppState>()(
@@ -36,8 +52,12 @@ export const useStore = create<AppState>()(
savedQueries: [],
darkMode: false,
sqlFormatOptions: { keywordCase: 'upper' },
sqlLogs: [],
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
})),
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
addTab: (tab) => set((state) => {
@@ -76,10 +96,13 @@ export const useStore = create<AppState>()(
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
clearSqlLogs: () => set({ sqlLogs: [] }),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions }), // Persist darkMode too
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions }), // Don't persist logs
}
)
);

View File

@@ -26,6 +26,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -50,6 +50,10 @@ export function DBShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
export function ExportTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
}

View File

@@ -287,5 +287,94 @@ data, columns, err := dbInst.Query(query)
f.WriteString("\n]")
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}
// ExportData exports provided data to a file
func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult {
if defaultName == "" {
defaultName = "export"
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export Data",
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
format = strings.ToLower(format)
var csvWriter *csv.Writer
var jsonEncoder *json.Encoder
var isJsonFirstRow = true
switch format {
case "csv", "xlsx":
f.Write([]byte{0xEF, 0xBB, 0xBF})
csvWriter = csv.NewWriter(f)
defer csvWriter.Flush()
if err := csvWriter.Write(columns); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
case "json":
f.WriteString("[\n")
jsonEncoder = json.NewEncoder(f)
jsonEncoder.SetIndent(" ", " ")
case "md":
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
seps := make([]string, len(columns))
for i := range seps {
seps[i] = "---"
}
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
default:
return connection.QueryResult{Success: false, Message: "Unsupported format: " + format}
}
for _, rowMap := range data {
record := make([]string, len(columns))
for i, col := range columns {
val := rowMap[col]
if val == nil {
record[i] = "NULL"
} else {
s := fmt.Sprintf("%v", val)
if format == "md" {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", "<br>")
}
record[i] = s
}
}
switch format {
case "csv", "xlsx":
if err := csvWriter.Write(record); err != nil {
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
case "json":
if !isJsonFirstRow {
f.WriteString(",\n")
}
if err := jsonEncoder.Encode(rowMap); err != nil {
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
isJsonFirstRow = false
case "md":
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
}
}
if format == "json" {
f.WriteString("\n]")
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}