diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9187051..ddb9813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f93c09 --- /dev/null +++ b/README.md @@ -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) 开源。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0921cff..0c790dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(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' }} > -
+
GoNavi
- + +
+ +
+ + {/* Sidebar Footer for Log Toggle */} +
+ +
{/* Sidebar Resize Handle */}
- - + +
+ +
+ {isLogPanelOpen && ( + setIsLogPanelOpen(false)} + onResizeStart={handleLogResizeStart} + /> + )}
- setIsModalOpen(false)} /> + - {/* Ghost Resize Line */} + {/* Ghost Resize Line for Sidebar */}
+ + {/* Ghost Resize Line for Log Panel */} +
); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ed1a767..9d2e9c6 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -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 ( 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 >
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 = React.memo(({ ) : ( -
+
{children}
); } - return {childNode}; + return {childNode}; }); 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: , + 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 = ({ 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>({}); @@ -483,14 +508,41 @@ const DataGrid: React.FC = ({ 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 = ({ 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: ( +
+

您未选中任何行,请选择导出范围:

+
+ + + +
+
+ ), + icon: , + okButtonProps: { style: { display: 'none' } }, // Hide default OK + maskClosable: true, + }); }; const handleImport = async () => { @@ -645,15 +737,20 @@ const DataGrid: React.FC = ({
))}
- - + + +
)}
+ {contextHolder} - + = ({ tab }) => { const [columnNames, setColumnNames] = useState([]); const [pkColumns, setPkColumns] = useState([]); 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]); diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx new file mode 100644 index 0000000..0328602 --- /dev/null +++ b/frontend/src/components/LogPanel.tsx @@ -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 = ({ height, onClose, onResizeStart }) => { + const { sqlLogs, clearSqlLogs, darkMode } = useStore(); + + const columns = [ + { + title: 'Time', + dataIndex: 'timestamp', + width: 80, + render: (ts: number) => {new Date(ts).toLocaleTimeString()} + }, + { + title: 'Status', + dataIndex: 'status', + width: 70, + render: (status: string) => ( + + {status === 'success' ? 'OK' : 'ERR'} + + ) + }, + { + title: 'Duration', + dataIndex: 'duration', + width: 70, + render: (d: number) => 1000 ? 'orange' : 'inherit', fontSize: '12px' }}>{d}ms + }, + { + title: 'SQL / Message', + dataIndex: 'sql', + render: (text: string, record: any) => ( +
+
{text}
+ {record.message &&
{record.message}
} + {record.affectedRows !== undefined &&
Affected: {record.affectedRows}
} +
+ ) + } + ]; + + return ( +
+ {/* Resize Handle */} +
+ + {/* Toolbar */} +
+
+ SQL 执行日志 +
+
+ +
+
+ + {/* List */} +
+
+ + + ); +}; + +export default LogPanel; \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index eb8bddb..c662717 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -34,7 +34,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const tablesRef = useRef([]); // 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); }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 132ac21..11b630a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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([]); const [searchValue, setSearchValue] = useState(''); const [expandedKeys, setExpandedKeys] = useState([]); const [autoExpandParent, setAutoExpandParent] = useState(true); + // Connection Status State: key -> 'success' | 'error' + const [connectionStates, setConnectionStates] = useState>({}); + // 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' ? ( + + ) : null; + if (node.type === 'connection') { const items: MenuProps['items'] = [ { @@ -457,10 +484,50 @@ const Sidebar: React.FC = () => { }); } }, + { type: 'divider' }, + { + key: 'edit', + label: '编辑连接', + icon: , + onClick: () => { + if (onEditConnection) onEditConnection(node.dataRef); + } + }, + { + key: 'disconnect', + label: '断开连接', + icon: , + 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: , + danger: true, + onClick: () => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除连接 "${node.title}" 吗?`, + onOk: () => removeConnection(node.key) + }); + } + } ]; return ( - {node.title} + {statusBadge}{node.title} ); } else if (node.type === 'database') { @@ -478,6 +545,23 @@ const Sidebar: React.FC = () => { onClick: () => loadTables(node) }, { type: 'divider' }, + { + key: 'disconnect-db', + label: '关闭数据库', + icon: , + 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 ( - {node.title} + {statusBadge}{node.title} ); } else if (node.type === 'table') { @@ -554,7 +638,7 @@ const Sidebar: React.FC = () => {
-
+
{ onExpand={onExpand} autoExpandParent={autoExpandParent} blockNode - height={500} />
diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 72205ff..edada6b 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -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(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([]); @@ -531,7 +546,15 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { }), })); - const columnsTabContent = readOnly ? ( + const columnsTabContent = ( +
+ + {readOnly ? (
= ({ 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 }) => { /> + )} + ); return ( diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5a7e4d2..68d7b2a 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -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()( @@ -36,8 +52,12 @@ export const useStore = create()( 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()( 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 } ) ); \ No newline at end of file diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 1e27fdf..fab02eb 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -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; +export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; + export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 250b49a..b6df5cb 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index ffea478..5a34e61 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -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", "
") + } + 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"} } \ No newline at end of file