mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 20:59:49 +08:00
Merge pull request #7 from Syngnat/feature/init-20260202-ygf
- 新增导出选中数据/当前页功能,支持 CSV/JSON/MD 格式 - 实现导出文件名默认使用表名 - 在侧边栏增加连接状态指示灯(红/绿/灰)及断开连接功能 - 优化数据表格点击交互,改为双击编辑防止误触 - 修复侧边栏滚动条显示及内容截断问题 - 修复 SQL 调试日志及导出时的上下文引用错误
This commit is contained in:
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -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
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# GoNavi - 现代化的轻量级数据库管理工具
|
||||
|
||||

|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://wails.io)
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](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) 开源。
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
114
frontend/src/components/LogPanel.tsx
Normal file
114
frontend/src/components/LogPanel.tsx
Normal 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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
);
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
Reference in New Issue
Block a user