mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 00:29:42 +08:00
初始化
This commit is contained in:
47
frontend/README.md
Normal file
47
frontend/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Lite DB Client
|
||||
|
||||
一个基于 Electron、React 和 Ant Design 构建的轻量级 MySQL 数据库客户端。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- **连接管理**: 轻松创建和保存 MySQL 数据库连接。
|
||||
- **结构浏览**: 通过树形视图快速查看数据库和表结构。
|
||||
- **数据查看**: 双击表名即可查看数据(支持分页/滚动加载)。
|
||||
- **SQL 编辑器**: 集成 Monaco Editor,提供强大的 SQL 编写和执行体验(支持语法高亮)。
|
||||
- **多标签页**: 支持多窗口并行操作,类似 Navicat 的使用体验。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **Electron**: 桌面端运行环境。
|
||||
- **React + Vite**: 前端框架与极速构建工具。
|
||||
- **Ant Design**: 企业级 UI 组件库。
|
||||
- **Zustand**: 轻量级状态管理。
|
||||
- **MySQL2**: 高性能 Node.js MySQL 驱动。
|
||||
- **Monaco Editor**: VS Code 同款代码编辑器。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
1. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **启动开发模式**
|
||||
```bash
|
||||
npm run electron:dev
|
||||
```
|
||||
这将启动 Vite 开发服务器并打开 Electron 窗口。
|
||||
|
||||
3. **构建生产版本**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
构建完成的安装包(dmg/exe/deb)将位于 `dist/` 或 `release/` 目录下。
|
||||
|
||||
## ⚠️ 说明
|
||||
|
||||
- 本项目目前处于 MVP (最小可行性产品) 阶段。
|
||||
- 当前版本主要支持 MySQL 数据库。
|
||||
- 密码目前保存在内存/本地存储中,请注意在生产环境中的安全性。
|
||||
|
||||
希望这款轻量级工具能成为你开发路上的好帮手!
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GoNavi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3112
frontend/package-lock.json
generated
Normal file
3112
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
1
frontend/package.json.md5
Executable file
1
frontend/package.json.md5
Executable file
@@ -0,0 +1 @@
|
||||
c1af19c07654ec9f98628c358ae49b1a
|
||||
25
frontend/src/App.css
Normal file
25
frontend/src/App.css
Normal file
@@ -0,0 +1,25 @@
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
.ant-tree .ant-tree-treenode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-tree .ant-tree-node-content-wrapper {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-tree .ant-tree-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 8px;
|
||||
}
|
||||
92
frontend/src/App.tsx
Normal file
92
frontend/src/App.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme } from 'antd';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import { useStore } from './store';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { darkMode, toggleDarkMode } = useStore();
|
||||
|
||||
// Sidebar Resizing
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
||||
|
||||
const handleSidebarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
||||
document.addEventListener('mousemove', handleSidebarMouseMove);
|
||||
document.addEventListener('mouseup', handleSidebarMouseUp);
|
||||
};
|
||||
|
||||
const handleSidebarMouseMove = (e: MouseEvent) => {
|
||||
if (!sidebarDragRef.current) return;
|
||||
const delta = e.clientX - sidebarDragRef.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleSidebarMouseUp = () => {
|
||||
sidebarDragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleSidebarMouseMove);
|
||||
document.removeEventListener('mouseup', handleSidebarMouseUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.body.style.backgroundColor = '#141414';
|
||||
document.body.style.color = '#ffffff';
|
||||
} else {
|
||||
document.body.style.backgroundColor = '#ffffff';
|
||||
document.body.style.color = '#000000';
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Sider theme={darkMode ? "dark" : "light"} width={sidebarWidth} style={{ borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', position: 'relative' }}>
|
||||
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
||||
<div>
|
||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar />
|
||||
|
||||
{/* Sidebar Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '5px',
|
||||
cursor: 'col-resize',
|
||||
zIndex: 100,
|
||||
// background: 'transparent' // transparent usually, visible on hover if desired
|
||||
}}
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: darkMode ? '#141414' : '#fff' }}>
|
||||
<TabManager />
|
||||
</Content>
|
||||
<ConnectionModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
162
frontend/src/components/ConnectionModal.tsx
Normal file
162
frontend/src/components/ConnectionModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLConnect } from '../../wailsjs/go/main/App';
|
||||
|
||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
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 handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const sshConfig = values.useSSH ? {
|
||||
host: values.sshHost,
|
||||
port: Number(values.sshPort),
|
||||
user: values.sshUser,
|
||||
password: values.sshPassword || "",
|
||||
keyPath: values.sshKeyPath || ""
|
||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||
|
||||
const config = {
|
||||
type: values.type,
|
||||
host: values.host,
|
||||
port: Number(values.port || 0),
|
||||
user: values.user || "",
|
||||
password: values.password || "",
|
||||
database: values.database || "",
|
||||
useSSH: !!values.useSSH,
|
||||
ssh: sshConfig
|
||||
};
|
||||
|
||||
const res = await MySQLConnect(config as any);
|
||||
setLoading(false);
|
||||
|
||||
if (res.success) {
|
||||
addConnection({
|
||||
id: Date.now().toString(),
|
||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
|
||||
config: config
|
||||
});
|
||||
message.success('连接已保存!');
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setDbType('mysql');
|
||||
onClose();
|
||||
} else {
|
||||
message.error('连接失败: ' + res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSqlite = dbType === 'sqlite';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建连接"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
zIndex={10001} // Increase z-index
|
||||
destroyOnClose // Reset on close
|
||||
maskClosable={false} // Prevent accidental close by clicking mask, user must click X or Cancel
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ type: 'mysql', host: 'localhost', port: 3306, user: 'root', useSSH: false, sshPort: 22 }}
|
||||
onValuesChange={(changed) => {
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="数据库类型" style={{ width: 120 }}>
|
||||
<Select>
|
||||
<Select.Option value="mysql">MySQL</Select.Option>
|
||||
<Select.Option value="postgres">PostgreSQL</Select.Option>
|
||||
<Select.Option value="sqlite">SQLite</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="连接名称" style={{ flex: 1 }}>
|
||||
<Input placeholder="例如:本地测试库" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="host" label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"} rules={[{ required: true, message: '请输入地址/路径' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"} />
|
||||
</Form.Item>
|
||||
{!isSqlite && (
|
||||
<Form.Item name="port" label="端口 (Port)" rules={[{ required: true, message: '请输入端口号' }]} style={{ width: 100 }}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSqlite && (
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="user" label="用户名" rules={[{ required: true, message: '请输入用户名' }]} style={{ flex: 1 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" style={{ flex: 1 }}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSqlite && (
|
||||
<Form.Item name="database" label="默认数据库 (可选)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{!isSqlite && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item name="useSSH" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Checkbox>使用 SSH 隧道 (SSH Tunnel)</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
{useSSH && (
|
||||
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="sshHost" label="SSH 主机" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="ssh.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="sshUser" label="SSH 用户" rules={[{ required: useSSH, message: '请输入SSH用户' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="root" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Input placeholder="绝对路径" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModal;
|
||||
655
frontend/src/components/DataViewer.tsx
Normal file
655
frontend/src/components/DataViewer.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import React, { useEffect, useState, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { Table, message, Spin, Input, Button, Space, Select, Tag, Dropdown, MenuProps, Form, Popconfirm, Pagination } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { SearchOutlined, FilterOutlined, CloseOutlined, ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, CheckOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { Resizable } from 'react-resizable';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLQuery, ImportData, ExportTable, ApplyChanges, DBGetColumns } from '../../wailsjs/go/main/App';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
if (typeof val === 'string') {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
|
||||
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
// --- Resizable Header ---
|
||||
const ResizableTitle = (props: any) => {
|
||||
const { onResize, width, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width}
|
||||
height={0}
|
||||
handle={
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -5,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 100,
|
||||
touchAction: 'none'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onResize={onResize}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
>
|
||||
<th
|
||||
{...restProps}
|
||||
style={{
|
||||
...restProps.style,
|
||||
position: 'relative',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
/>
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Contexts ---
|
||||
const EditableContext = React.createContext<any>(null);
|
||||
|
||||
// Use Ref for selection to prevent Context updates on every selection change
|
||||
const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
displayDataRef: React.MutableRefObject<any[]>;
|
||||
handleCopyInsert: (r: any) => void;
|
||||
handleCopyJson: (r: any) => void;
|
||||
handleCopyCsv: (r: any) => void;
|
||||
copyToClipboard: (t: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface EditableCellProps {
|
||||
title: React.ReactNode;
|
||||
editable: boolean;
|
||||
children: React.ReactNode;
|
||||
dataIndex: string;
|
||||
record: Item;
|
||||
handleSave: (record: Item) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Optimization: Memoize EditableCell
|
||||
const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
title,
|
||||
editable,
|
||||
children,
|
||||
dataIndex,
|
||||
record,
|
||||
handleSave,
|
||||
...restProps
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const form = useContext(EditableContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const toggleEdit = () => {
|
||||
setEditing(!editing);
|
||||
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form) return;
|
||||
const values = await form.validateFields();
|
||||
toggleEdit();
|
||||
handleSave({ ...record, ...values });
|
||||
} catch (errInfo) {
|
||||
console.log('Save failed:', errInfo);
|
||||
}
|
||||
};
|
||||
|
||||
let childNode = children;
|
||||
|
||||
if (editable) {
|
||||
childNode = editing ? (
|
||||
<Form.Item
|
||||
style={{ margin: 0 }}
|
||||
name={dataIndex}
|
||||
>
|
||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <td {...restProps}>{childNode}</td>;
|
||||
});
|
||||
|
||||
// --- Context Menu Row Wrapper (External & Memoized) ---
|
||||
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
|
||||
const record = props.record;
|
||||
const context = useContext(DataContext);
|
||||
|
||||
if (!record || !context) {
|
||||
return <tr {...props}>{children}</tr>;
|
||||
}
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
|
||||
|
||||
const getTargets = () => {
|
||||
const keys = selectedRowKeysRef.current;
|
||||
if (keys.includes(record.key)) {
|
||||
return displayDataRef.current.filter(d => keys.includes(d.key));
|
||||
}
|
||||
return [record];
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'insert',
|
||||
label: `复制为 INSERT`,
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyInsert(record)
|
||||
},
|
||||
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
||||
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
||||
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
|
||||
const records = getTargets();
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
return `| ${Object.values(vals).join(' | ')} |`;
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<tr {...props}>{children}</tr>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const connections = useStore(state => state.connections);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [addedRows, setAddedRows] = useState<any[]>([]);
|
||||
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
|
||||
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
|
||||
|
||||
// Refs
|
||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||
const displayDataRef = useRef<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRowKeysRef.current = selectedRowKeys;
|
||||
}, [selectedRowKeys]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
|
||||
}, [data, addedRows, deletedRowKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
displayDataRef.current = displayData;
|
||||
}, [displayData]);
|
||||
|
||||
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
||||
|
||||
const fetchData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
setLoading(false);
|
||||
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 dbName = tab.dbName || '';
|
||||
const tableName = tab.tableName || '';
|
||||
|
||||
const whereParts: string[] = [];
|
||||
filterConditions.forEach(cond => {
|
||||
if (cond.column && cond.value) {
|
||||
if (cond.op === 'LIKE') {
|
||||
whereParts.push(`\`${cond.column}\` LIKE '%${cond.value}%'`);
|
||||
} else {
|
||||
whereParts.push(`\`${cond.column}\` ${cond.op} '${cond.value}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const whereSQL = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : "";
|
||||
|
||||
const countSql = `SELECT COUNT(*) as total FROM \`${tableName}\` ${whereSQL}`;
|
||||
|
||||
let sql = `SELECT * FROM \`${tableName}\` ${whereSQL}`;
|
||||
if (sortInfo && sortInfo.order) {
|
||||
sql += ` ORDER BY \`${sortInfo.columnKey}\` ${sortInfo.order === 'ascend' ? 'ASC' : 'DESC'}`;
|
||||
}
|
||||
const offset = (page - 1) * size;
|
||||
sql += ` LIMIT ${size} OFFSET ${offset}`;
|
||||
|
||||
try {
|
||||
const pCount = MySQLQuery(config as any, dbName, countSql);
|
||||
const pData = MySQLQuery(config as any, dbName, sql);
|
||||
|
||||
let pCols = null;
|
||||
if (pkColumns.length === 0) {
|
||||
pCols = DBGetColumns(config as any, dbName, tableName);
|
||||
}
|
||||
|
||||
const [resCount, resData] = await Promise.all([pCount, pData]);
|
||||
|
||||
if (pCols) {
|
||||
const resCols = await pCols;
|
||||
if (resCols.success) {
|
||||
const pks = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
|
||||
setPkColumns(pks);
|
||||
}
|
||||
}
|
||||
|
||||
let totalRecords = 0;
|
||||
if (resCount.success && Array.isArray(resCount.data) && resCount.data.length > 0) {
|
||||
totalRecords = Number(resCount.data[0]['total']);
|
||||
}
|
||||
|
||||
if (resData.success) {
|
||||
let resultData = resData.data as any[];
|
||||
if (!Array.isArray(resultData)) resultData = [];
|
||||
|
||||
let fieldNames = resData.fields || [];
|
||||
if (fieldNames.length === 0 && resultData.length > 0) {
|
||||
fieldNames = Object.keys(resultData[0]);
|
||||
}
|
||||
setColumnNames(fieldNames);
|
||||
|
||||
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
|
||||
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
|
||||
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
setSelectedRowKeys([]);
|
||||
} else {
|
||||
message.error(resData.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error("Error fetching data: " + e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1, pagination.pageSize);
|
||||
}, [tab, sortInfo]);
|
||||
|
||||
const handlePaginationChange = (page: number, pageSize: number) => {
|
||||
fetchData(page, pageSize);
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||
if (sorter.field) {
|
||||
setSortInfo({ columnKey: sorter.field as string, order: sorter.order as string });
|
||||
} else {
|
||||
setSortInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = useCallback((key: string) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnNames.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: true,
|
||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||
editable: true,
|
||||
render: (text: any) => formatCellValue(text),
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(key),
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResize]);
|
||||
|
||||
// Calculate total width
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
||||
|
||||
const handleCellSave = useCallback((row: any) => {
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const index = newData.findIndex(item => item.key === row.key);
|
||||
if (index > -1) {
|
||||
const item = newData[index];
|
||||
newData.splice(index, 1, { ...item, ...row });
|
||||
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
|
||||
return newData;
|
||||
}
|
||||
return prevData;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Compute merged columns for editable
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
return {
|
||||
...col,
|
||||
onCell: (record: Item) => ({
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
handleSave: handleCellSave,
|
||||
}),
|
||||
};
|
||||
}), [columns, handleCellSave]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
const newRow: any = { key: newKey };
|
||||
columnNames.forEach(col => newRow[col] = '');
|
||||
setAddedRows(prev => [...prev, newRow]);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
setDeletedRowKeys(prev => {
|
||||
const newDeleted = new Set(prev);
|
||||
selectedRowKeys.forEach(key => {
|
||||
newDeleted.add(key);
|
||||
});
|
||||
return newDeleted;
|
||||
});
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
const inserts: any[] = [];
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
|
||||
deletedRowKeys.forEach(key => {
|
||||
const originalRow = data.find(d => d.key === key);
|
||||
if (originalRow) {
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
deletes.push(pkData);
|
||||
}
|
||||
});
|
||||
Object.entries(modifiedRows).forEach(([key, newRow]) => {
|
||||
if (deletedRowKeys.has(key)) return;
|
||||
const originalRow = data.find(d => d.key === key);
|
||||
if (!originalRow) return;
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
const { key: _, ...vals } = newRow;
|
||||
updates.push({ keys: pkData, values: vals });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
message.info("No changes to commit");
|
||||
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 res = await ApplyChanges(config as any, tab.dbName || '', tab.tableName || '', { inserts, updates, deletes } as any);
|
||||
if (res.success) {
|
||||
message.success("Changes committed successfully!");
|
||||
fetchData();
|
||||
} else {
|
||||
message.error("Commit failed: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success("Copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const getTargets = useCallback((clickedRecord: any) => {
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
const currentData = displayDataRef.current;
|
||||
if (selKeys.includes(clickedRecord.key)) {
|
||||
return currentData.filter(d => selKeys.includes(d.key));
|
||||
}
|
||||
return [clickedRecord];
|
||||
}, []);
|
||||
|
||||
const handleCopyInsert = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const sqls = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const cols = Object.keys(vals);
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
|
||||
return `INSERT INTO \`${tab.tableName}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
copyToClipboard(sqls.join('\n'));
|
||||
}, [tab.tableName, getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyJson = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const cleanRecords = records.map((r: any) => {
|
||||
const { key, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyCsv = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
|
||||
return values.join(',');
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
// ... (Filter Handlers)
|
||||
const addFilter = () => {
|
||||
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
|
||||
setNextFilterId(nextFilterId + 1);
|
||||
setShowFilter(true);
|
||||
};
|
||||
const updateFilter = (id: number, field: string, val: string) => {
|
||||
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
|
||||
};
|
||||
const removeFilter = (id: number) => {
|
||||
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
||||
};
|
||||
const applyFilters = () => fetchData(1, pagination.pageSize);
|
||||
|
||||
const handleImport = async () => {
|
||||
const conn = connections.find(c => c.id === tab.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 res = await ImportData(config as any, tab.dbName || '', tab.tableName || '');
|
||||
if (res.success) { message.success(res.message); fetchData(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
||||
};
|
||||
|
||||
const handleExport = async (format: string) => {
|
||||
const conn = connections.find(c => c.id === tab.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, tab.dbName || '', tab.tableName || '', format);
|
||||
hide();
|
||||
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
|
||||
};
|
||||
|
||||
const exportMenu: MenuProps['items'] = [
|
||||
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
|
||||
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
||||
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
|
||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
];
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
selectedRowKeysRef,
|
||||
displayDataRef,
|
||||
handleCopyInsert,
|
||||
handleCopyJson,
|
||||
handleCopyCsv,
|
||||
copyToClipboard
|
||||
}), [handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard]);
|
||||
|
||||
const tableComponents = useMemo(() => ({
|
||||
body: { cell: EditableCell, row: ContextMenuRow },
|
||||
header: { cell: ResizableTitle }
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>刷新</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>
|
||||
<Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}>提交事务 ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
|
||||
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => fetchData()}>回滚</Button>)}
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => { setShowFilter(!showFilter); if (filterConditions.length === 0 && !showFilter) addFilter(); }}>筛选</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilter && (
|
||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
||||
{filterConditions.map(cond => (
|
||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
|
||||
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
|
||||
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
|
||||
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={contextValue}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={displayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: 'calc(100vh - 200px - 40px)' }}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
if (addedRows.includes(record)) return 'row-added';
|
||||
if (modifiedRows[record.key]) return 'row-modified';
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Pagination Bar */}
|
||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`}
|
||||
showSizeChanger
|
||||
pageSizeOptions={['100', '200', '500', '1000']}
|
||||
onChange={handlePaginationChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.row-added td { background-color: #f6ffed !important; }
|
||||
.row-modified td { background-color: #e6f7ff !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataViewer;
|
||||
333
frontend/src/components/QueryEditor.tsx
Normal file
333
frontend/src/components/QueryEditor.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import { Button, Table, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/main/App';
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [columns, setColumns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
const [saveForm] = Form.useForm();
|
||||
|
||||
// Resizing state
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||
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 saveQuery = useStore(state => state.saveQuery);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||
|
||||
// If opening a saved query, load its SQL
|
||||
useEffect(() => {
|
||||
if (tab.query) {
|
||||
setQuery(tab.query);
|
||||
}
|
||||
}, [tab.query]);
|
||||
|
||||
// Fetch Metadata for Autocomplete
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
const conn = connections.find(c => c.id === tab.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 dbName = tab.dbName || conn.config.database || "";
|
||||
|
||||
// Fetch Tables
|
||||
const resTables = await DBGetTables(config as any, dbName);
|
||||
if (resTables.success && Array.isArray(resTables.data)) {
|
||||
// res.data is [{Table: "name"}, ...]
|
||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||
tablesRef.current = tableNames;
|
||||
}
|
||||
|
||||
// Fetch All Columns (Optimized for autocomplete)
|
||||
if (config.type === 'mysql' || !config.type) {
|
||||
const resCols = await DBGetAllColumns(config as any, dbName);
|
||||
if (resCols.success && Array.isArray(resCols.data)) {
|
||||
allColumnsRef.current = resCols.data;
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchMetadata();
|
||||
}, [tab.connectionId, tab.dbName, connections]);
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = e.clientY - dragRef.current.startY;
|
||||
const newHeight = Math.max(100, Math.min(window.innerHeight - 200, dragRef.current.startHeight + delta));
|
||||
setEditorHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Setup Autocomplete and Editor
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// SQL Autocomplete
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
// Simple Heuristic: Find tables mentioned in the query
|
||||
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
|
||||
const foundTables = new Set<string>();
|
||||
let match;
|
||||
const fullText = model.getValue();
|
||||
while ((match = tableRegex.exec(fullText)) !== null) {
|
||||
foundTables.add(match[1]);
|
||||
}
|
||||
|
||||
// Columns suggestion
|
||||
const relevantColumns = allColumnsRef.current
|
||||
.filter(c => foundTables.has(c.tableName))
|
||||
.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: c.name,
|
||||
detail: `${c.type} (${c.tableName})`,
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
|
||||
const suggestions = [
|
||||
// Keywords
|
||||
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||
label: k,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: k,
|
||||
range
|
||||
})),
|
||||
// Tables
|
||||
...tablesRef.current.map(t => ({
|
||||
label: t,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: t,
|
||||
detail: 'Table',
|
||||
range
|
||||
})),
|
||||
// Columns
|
||||
...relevantColumns
|
||||
];
|
||||
return { suggestions };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = format(query, { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
||||
setQuery(formatted);
|
||||
} catch (e) {
|
||||
message.error("格式化失败: SQL 语法可能有误");
|
||||
}
|
||||
};
|
||||
|
||||
const formatSettingsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'upper',
|
||||
label: '关键字大写',
|
||||
icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'upper' })
|
||||
},
|
||||
{
|
||||
key: 'lower',
|
||||
label: '关键字小写',
|
||||
icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
|
||||
},
|
||||
];
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
setLoading(false);
|
||||
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 res = await MySQLQuery(config as any, tab.dbName || conn.config.database || '', query);
|
||||
|
||||
if (res.success) {
|
||||
if (Array.isArray(res.data)) {
|
||||
if (res.data.length > 0) {
|
||||
const cols = Object.keys(res.data[0]).map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
render: (text: any) => typeof text === 'object' ? JSON.stringify(text) : String(text),
|
||||
}));
|
||||
setColumns(cols);
|
||||
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
|
||||
} else {
|
||||
message.info('查询执行成功,但没有返回结果。');
|
||||
setResults([]);
|
||||
setColumns([]);
|
||||
}
|
||||
} else {
|
||||
// Handle update/insert results
|
||||
const affected = (res.data as any).affectedRows;
|
||||
message.success(`受影响行数: ${affected}`);
|
||||
setResults([]);
|
||||
}
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await saveForm.validateFields();
|
||||
saveQuery({
|
||||
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
||||
name: values.name,
|
||||
sql: query,
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName || '',
|
||||
createdAt: Date.now()
|
||||
});
|
||||
message.success('查询已保存!');
|
||||
setIsSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
// validation failed
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||
运行
|
||||
</Button>
|
||||
<Button icon={<SaveOutlined />} onClick={() => {
|
||||
saveForm.setFieldsValue({ name: tab.title.replace('Query (', '').replace(')', '') });
|
||||
setIsSaveModalOpen(true);
|
||||
}}>
|
||||
保存
|
||||
</Button>
|
||||
|
||||
<Button.Group>
|
||||
<Tooltip title="美化 SQL">
|
||||
<Button icon={<FormatPainterOutlined />} onClick={handleFormat}>美化</Button>
|
||||
</Tooltip>
|
||||
<Dropdown menu={{ items: formatSettingsMenu }} placement="bottomRight">
|
||||
<Button icon={<SettingOutlined />} />
|
||||
</Dropdown>
|
||||
</Button.Group>
|
||||
</div>
|
||||
|
||||
{/* Editor Area - Resizable */}
|
||||
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? "vs-dark" : "light"}
|
||||
value={query}
|
||||
onChange={(val) => setQuery(val || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
height: '5px',
|
||||
cursor: 'row-resize',
|
||||
background: darkMode ? '#333' : '#f0f0f0',
|
||||
flexShrink: 0,
|
||||
zIndex: 10
|
||||
}}
|
||||
title="拖动调整高度"
|
||||
/>
|
||||
|
||||
{/* Results Area - Fills remaining space */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', padding: 10, display: 'flex', flexDirection: 'column' }}>
|
||||
<Table
|
||||
dataSource={results}
|
||||
columns={columns}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content', y: 'calc(100% - 40px)' }}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="保存查询"
|
||||
open={isSaveModalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setIsSaveModalOpen(false)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={saveForm} layout="vertical">
|
||||
<Form.Item name="name" label="查询名称" rules={[{ required: true, message: '请输入查询名称' }]}>
|
||||
<Input placeholder="例如:查询所有用户" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
||||
554
frontend/src/components/Sidebar.tsx
Normal file
554
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
TableOutlined,
|
||||
ConsoleSqlOutlined,
|
||||
HddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FileTextOutlined,
|
||||
CopyOutlined,
|
||||
ExportOutlined,
|
||||
SaveOutlined,
|
||||
EditOutlined,
|
||||
DownOutlined,
|
||||
SearchOutlined,
|
||||
KeyOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
FunctionOutlined,
|
||||
LinkOutlined,
|
||||
FileAddOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/main/App';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
interface TreeNode {
|
||||
title: string;
|
||||
key: string;
|
||||
isLeaf?: boolean;
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers';
|
||||
}
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { connections, savedQueries, addTab } = useStore();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
|
||||
// Create Database Modal
|
||||
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
|
||||
const [createDbForm] = Form.useForm();
|
||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeData(connections.map(conn => ({
|
||||
title: conn.name,
|
||||
key: conn.id,
|
||||
icon: <HddOutlined />,
|
||||
type: 'connection',
|
||||
dataRef: conn,
|
||||
isLeaf: false,
|
||||
})));
|
||||
}, [connections]);
|
||||
|
||||
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[]): TreeNode[] => {
|
||||
return list.map(node => {
|
||||
if (node.key === key) {
|
||||
return { ...node, children };
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: updateTreeData(node.children, key, children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
const loadDatabases = async (node: any) => {
|
||||
const conn = node.dataRef as SavedConnection;
|
||||
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 res = await MySQLGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
const dbs = (res.data as any[]).map((row: any) => ({
|
||||
title: row.Database || row.database,
|
||||
key: `${conn.id}-${row.Database || row.database}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
type: 'database' as const,
|
||||
dataRef: { ...conn, dbName: row.Database || row.database },
|
||||
isLeaf: false,
|
||||
}));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (node: any) => {
|
||||
const conn = node.dataRef; // has dbName
|
||||
const dbName = conn.dbName;
|
||||
const key = node.key;
|
||||
|
||||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||||
|
||||
const queriesNode: TreeNode = {
|
||||
title: '已存查询',
|
||||
key: `${key}-queries`,
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'queries-folder',
|
||||
isLeaf: dbQueries.length === 0,
|
||||
children: dbQueries.map(q => ({
|
||||
title: q.name,
|
||||
key: q.id,
|
||||
icon: <FileTextOutlined />,
|
||||
type: 'saved-query',
|
||||
dataRef: q,
|
||||
isLeaf: true
|
||||
}))
|
||||
};
|
||||
|
||||
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 res = await MySQLGetTables(config as any, conn.dbName);
|
||||
if (res.success) {
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table' as const,
|
||||
dataRef: { ...conn, tableName },
|
||||
isLeaf: false,
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
if (children) return;
|
||||
|
||||
if (type === 'connection') {
|
||||
await loadDatabases({ key, dataRef });
|
||||
} else if (type === 'database') {
|
||||
await loadTables({ key, dataRef });
|
||||
} else if (type === 'table') {
|
||||
// Expand table to show object categories
|
||||
const { tableName, dbName, id } = dataRef;
|
||||
const conn = dataRef;
|
||||
|
||||
const folders: TreeNode[] = [
|
||||
{
|
||||
title: '列',
|
||||
key: `${key}-columns`,
|
||||
icon: <UnorderedListOutlined />,
|
||||
type: 'folder-columns',
|
||||
isLeaf: true,
|
||||
dataRef: conn
|
||||
},
|
||||
{
|
||||
title: '索引',
|
||||
key: `${key}-indexes`,
|
||||
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
|
||||
type: 'folder-indexes',
|
||||
isLeaf: true,
|
||||
dataRef: conn
|
||||
},
|
||||
{
|
||||
title: '外键',
|
||||
key: `${key}-fks`,
|
||||
icon: <LinkOutlined />,
|
||||
type: 'folder-fks',
|
||||
isLeaf: true,
|
||||
dataRef: conn
|
||||
},
|
||||
{
|
||||
title: '触发器',
|
||||
key: `${key}-triggers`,
|
||||
icon: <ThunderboltOutlined />,
|
||||
type: 'folder-triggers',
|
||||
isLeaf: true,
|
||||
dataRef: conn
|
||||
}
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, folders));
|
||||
}
|
||||
};
|
||||
|
||||
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
|
||||
const { tableName, dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: `design-${id}-${dbName}-${tableName}`,
|
||||
title: `${readOnly ? '表结构' : '设计表'} (${tableName})`,
|
||||
type: 'design',
|
||||
connectionId: id,
|
||||
dbName: dbName,
|
||||
tableName: tableName,
|
||||
initialTab: initialTab,
|
||||
readOnly: readOnly
|
||||
});
|
||||
};
|
||||
|
||||
const openNewTableDesign = (node: any) => {
|
||||
const { dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: `new-table-${id}-${dbName}-${Date.now()}`,
|
||||
title: `新建表 - ${dbName}`,
|
||||
type: 'design',
|
||||
connectionId: id,
|
||||
dbName: dbName,
|
||||
tableName: '', // Empty tableName signals creation mode
|
||||
initialTab: 'columns',
|
||||
readOnly: false
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (keys: React.Key[], info: any) => {
|
||||
if (!info.node.selected) return;
|
||||
|
||||
const { type, dataRef } = info.node;
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
setExpandedKeys(newExpandedKeys);
|
||||
setAutoExpandParent(false);
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: any, node: any) => {
|
||||
const key = node.key;
|
||||
const isExpanded = expandedKeys.includes(key);
|
||||
const newExpandedKeys = isExpanded
|
||||
? expandedKeys.filter(k => k !== key)
|
||||
: [...expandedKeys, key];
|
||||
|
||||
setExpandedKeys(newExpandedKeys);
|
||||
if (!isExpanded) setAutoExpandParent(false);
|
||||
|
||||
if (node.type === 'table') {
|
||||
const { tableName, dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: node.key,
|
||||
title: tableName,
|
||||
type: 'table',
|
||||
connectionId: id,
|
||||
dbName,
|
||||
tableName,
|
||||
});
|
||||
} else if (node.type === 'saved-query') {
|
||||
const q = node.dataRef;
|
||||
addTab({
|
||||
id: q.id,
|
||||
title: q.name,
|
||||
type: 'query',
|
||||
connectionId: q.connectionId,
|
||||
dbName: q.dbName,
|
||||
query: q.sql
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyStructure = async (node: any) => {
|
||||
const { config, dbName, tableName } = node.dataRef;
|
||||
const res = await MySQLShowCreateTable({
|
||||
...config,
|
||||
port: Number(config.port),
|
||||
password: config.password || "",
|
||||
database: config.database || "",
|
||||
useSSH: config.useSSH || false,
|
||||
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
} as any, dbName, tableName);
|
||||
if (res.success) {
|
||||
navigator.clipboard.writeText(res.data as string);
|
||||
message.success('表结构已复制到剪贴板');
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (node: any, format: string) => {
|
||||
const { config, dbName, tableName } = node.dataRef;
|
||||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable({
|
||||
...config,
|
||||
port: Number(config.port),
|
||||
password: config.password || "",
|
||||
database: config.database || "",
|
||||
useSSH: config.useSSH || false,
|
||||
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
} as any, dbName, tableName, format);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunSQLFile = async (node: any) => {
|
||||
const res = await (window as any).go.main.App.OpenSQLFile();
|
||||
if (res.success) {
|
||||
const sqlContent = res.data;
|
||||
const { dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `Import SQL`,
|
||||
type: 'query',
|
||||
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
|
||||
dbName: dbName,
|
||||
query: sqlContent
|
||||
});
|
||||
} else if (res.message !== "Cancelled") {
|
||||
message.error("读取文件失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDatabase = async () => {
|
||||
try {
|
||||
const values = await createDbForm.validateFields();
|
||||
const conn = targetConnection.dataRef;
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: "", // No db selected
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await CreateDatabase(config as any, values.name);
|
||||
if (res.success) {
|
||||
message.success("数据库创建成功");
|
||||
setIsCreateDbModalOpen(false);
|
||||
createDbForm.resetFields();
|
||||
// Refresh node
|
||||
loadDatabases(targetConnection);
|
||||
} else {
|
||||
message.error("创建失败: " + res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
// Validate failed
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
setSearchValue(value);
|
||||
};
|
||||
|
||||
const loop = (data: TreeNode[]): TreeNode[] => {
|
||||
const result: TreeNode[] = [];
|
||||
data.forEach(item => {
|
||||
const match = item.title.toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
|
||||
if (item.children) {
|
||||
const filteredChildren = loop(item.children);
|
||||
if (filteredChildren.length > 0 || match) {
|
||||
result.push({ ...item, children: filteredChildren });
|
||||
}
|
||||
} else {
|
||||
if (match) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const displayTreeData = useMemo(() => {
|
||||
if (!searchValue) return treeData;
|
||||
return loop(treeData);
|
||||
}, [searchValue, treeData]);
|
||||
|
||||
const titleRender = (node: any) => {
|
||||
if (node.type === 'connection') {
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'new-db',
|
||||
label: '新建数据库',
|
||||
icon: <DatabaseOutlined />,
|
||||
onClick: () => {
|
||||
setTargetConnection(node);
|
||||
setIsCreateDbModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: () => loadDatabases(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.key,
|
||||
dbName: undefined
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
} else if (node.type === 'database') {
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'new-table',
|
||||
label: '新建表',
|
||||
icon: <TableOutlined />,
|
||||
onClick: () => openNewTableDesign(node)
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: () => loadTables(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询 (${node.title})`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.title
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'run-sql',
|
||||
label: '运行 SQL 文件...',
|
||||
icon: <FileAddOutlined />,
|
||||
onClick: () => handleRunSQLFile(node)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
} else if (node.type === 'table') {
|
||||
const contextMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'design-table',
|
||||
label: '设计表',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openDesign(node, 'columns', false)
|
||||
},
|
||||
{
|
||||
key: 'copy-structure',
|
||||
label: '复制表结构',
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => handleCopyStructure(node)
|
||||
},
|
||||
{
|
||||
key: 'backup-table',
|
||||
label: '备份表 (SQL)',
|
||||
icon: <SaveOutlined />,
|
||||
onClick: () => handleExport(node, 'sql')
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出表数据',
|
||||
icon: <ExportOutlined />,
|
||||
children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: contextMenu, triggerSubMenuAction: 'click' }} trigger={['contextMenu']}>
|
||||
<span title={node.title}>{node.title}</span>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
return <span title={node.title}>{node.title}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<Search placeholder="搜索..." onChange={onSearch} size="small" />
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Tree
|
||||
showIcon
|
||||
loadData={onLoadData}
|
||||
treeData={displayTreeData}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onSelect={onSelect}
|
||||
titleRender={titleRender}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={onExpand}
|
||||
autoExpandParent={autoExpandParent}
|
||||
blockNode
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="新建数据库"
|
||||
open={isCreateDbModalOpen}
|
||||
onOk={handleCreateDatabase}
|
||||
onCancel={() => setIsCreateDbModalOpen(false)}
|
||||
>
|
||||
<Form form={createDbForm} layout="vertical">
|
||||
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{/* Charset option could be added here */}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
51
frontend/src/components/TabManager.tsx
Normal file
51
frontend/src/components/TabManager.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Button } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import DataViewer from './DataViewer';
|
||||
import QueryEditor from './QueryEditor';
|
||||
import TableDesigner from './TableDesigner';
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const { tabs, activeTabId, setActiveTab, closeTab } = useStore();
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
setActiveTab(newActiveKey);
|
||||
};
|
||||
|
||||
const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => {
|
||||
if (action === 'remove') {
|
||||
closeTab(targetKey as string);
|
||||
}
|
||||
};
|
||||
|
||||
const items = tabs.map(tab => {
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
} else if (tab.type === 'design') {
|
||||
content = <TableDesigner tab={tab} />;
|
||||
}
|
||||
|
||||
return {
|
||||
label: tab.title,
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
style={{ height: '100%' }}
|
||||
hideAdd
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabManager;
|
||||
695
frontend/src/components/TableDesigner.tsx
Normal file
695
frontend/src/components/TableDesigner.tsx
Normal file
@@ -0,0 +1,695 @@
|
||||
import React, { useEffect, useState, useContext, useMemo } 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';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Resizable } from 'react-resizable';
|
||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Need styles for react-resizable
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
isNew?: boolean;
|
||||
isAutoIncrement?: boolean; // Virtual field for UI
|
||||
}
|
||||
|
||||
const COMMON_TYPES = [
|
||||
{ value: 'int' },
|
||||
{ value: 'varchar(255)' },
|
||||
{ value: 'text' },
|
||||
{ value: 'datetime' },
|
||||
{ value: 'tinyint(1)' },
|
||||
{ value: 'decimal(10,2)' },
|
||||
{ value: 'bigint' },
|
||||
{ value: 'json' },
|
||||
];
|
||||
|
||||
const COMMON_DEFAULTS = [
|
||||
{ value: 'CURRENT_TIMESTAMP' },
|
||||
{ value: 'NULL' },
|
||||
{ value: '0' },
|
||||
{ value: "''" },
|
||||
];
|
||||
|
||||
const CHARSETS = [
|
||||
{ label: 'utf8mb4 (Recommended)', value: 'utf8mb4' },
|
||||
{ label: 'utf8', value: 'utf8' },
|
||||
{ label: 'latin1', value: 'latin1' },
|
||||
{ label: 'ascii', value: 'ascii' },
|
||||
];
|
||||
|
||||
const COLLATIONS = {
|
||||
'utf8mb4': [
|
||||
{ label: 'utf8mb4_unicode_ci (Default)', value: 'utf8mb4_unicode_ci' },
|
||||
{ label: 'utf8mb4_general_ci', value: 'utf8mb4_general_ci' },
|
||||
{ label: 'utf8mb4_bin', value: 'utf8mb4_bin' },
|
||||
{ label: 'utf8mb4_0900_ai_ci', value: 'utf8mb4_0900_ai_ci' },
|
||||
],
|
||||
'utf8': [
|
||||
{ label: 'utf8_unicode_ci', value: 'utf8_unicode_ci' },
|
||||
{ label: 'utf8_general_ci', value: 'utf8_general_ci' },
|
||||
{ label: 'utf8_bin', value: 'utf8_bin' },
|
||||
]
|
||||
};
|
||||
|
||||
// --- Resizable Header Component ---
|
||||
const ResizableTitle = (props: any) => {
|
||||
const { onResize, width, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width}
|
||||
height={0}
|
||||
handle={
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -5,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onResize={onResize}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
>
|
||||
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sortable Row Component ---
|
||||
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
||||
'data-row-key': string;
|
||||
}
|
||||
|
||||
const SortableRow = ({ children, ...props }: RowProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: props['data-row-key'],
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...props.style,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
cursor: 'move',
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, child => {
|
||||
if ((child as React.ReactElement).key === 'sort') {
|
||||
return React.cloneElement(child as React.ReactElement, {
|
||||
children: (
|
||||
<MenuOutlined
|
||||
style={{ cursor: 'grab', color: '#999' }}
|
||||
{...listeners}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const isNewTable = !tab.tableName;
|
||||
|
||||
const [columns, setColumns] = useState<EditableColumn[]>([]);
|
||||
const [originalColumns, setOriginalColumns] = useState<EditableColumn[]>([]);
|
||||
const [indexes, setIndexes] = useState<IndexDefinition[]>([]);
|
||||
const [fks, setFks] = useState<ForeignKeyDefinition[]>([]);
|
||||
const [triggers, setTriggers] = useState<TriggerDefinition[]>([]);
|
||||
const [ddl, setDdl] = useState<string>('');
|
||||
|
||||
// New Table State
|
||||
const [newTableName, setNewTableName] = useState('');
|
||||
const [charset, setCharset] = useState('utf8mb4');
|
||||
const [collation, setCollation] = useState('utf8mb4_unicode_ci');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewSql, setPreviewSql] = useState<string>('');
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const readOnly = !!tab.readOnly;
|
||||
|
||||
// --- Resizable Columns State ---
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab.initialTab) {
|
||||
setActiveKey(tab.initialTab);
|
||||
}
|
||||
}, [tab.initialTab]);
|
||||
|
||||
// Initial Columns Definition
|
||||
useEffect(() => {
|
||||
const initialCols = [
|
||||
...(readOnly ? [] : [{
|
||||
key: 'sort',
|
||||
width: 40,
|
||||
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
|
||||
}]),
|
||||
{
|
||||
title: '名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 150,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<AutoComplete options={COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '主键',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (text: string, record: EditableColumn) => (
|
||||
<Checkbox checked={text === 'PRI'} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '自增',
|
||||
dataIndex: 'isAutoIncrement',
|
||||
key: 'isAutoIncrement',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (val: boolean, record: EditableColumn) => (
|
||||
<Checkbox checked={val} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '不是 Null',
|
||||
dataIndex: 'nullable',
|
||||
key: 'nullable',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (text: string, record: EditableColumn) => (
|
||||
<Checkbox checked={text === 'NO'} disabled={readOnly || record.key === 'PRI'} onChange={e => handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'default',
|
||||
key: 'default',
|
||||
width: 180, // Increased default width
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<AutoComplete options={COMMON_DEFAULTS} value={text} onChange={val => handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '注释',
|
||||
dataIndex: 'comment',
|
||||
key: 'comment',
|
||||
width: 200,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'comment', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
...(readOnly ? [] : [{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 60,
|
||||
render: (_: any, record: EditableColumn) => (
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDeleteColumn(record._key)} />
|
||||
)
|
||||
}])
|
||||
];
|
||||
setTableColumns(initialCols);
|
||||
}, [readOnly]); // Re-create if readOnly changes
|
||||
|
||||
// Resize Handler
|
||||
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
||||
setTableColumns((columns) => {
|
||||
const nextColumns = [...columns];
|
||||
nextColumns[index] = {
|
||||
...nextColumns[index],
|
||||
width: size.width,
|
||||
};
|
||||
return nextColumns;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (isNewTable) return; // Don't fetch for new table
|
||||
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
setLoading(false);
|
||||
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 promises: Promise<any>[] = [
|
||||
DBGetColumns(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetIndexes(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetForeignKeys(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '')
|
||||
];
|
||||
|
||||
if (readOnly) {
|
||||
promises.push(DBShowCreateTable(config as any, tab.dbName || '', tab.tableName || ''));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const colsRes = results[0];
|
||||
const idxRes = results[1];
|
||||
const fkRes = results[2];
|
||||
const trigRes = results[3];
|
||||
const ddlRes = readOnly ? results[4] : null;
|
||||
|
||||
if (colsRes.success) {
|
||||
const colsWithKey = (colsRes.data as ColumnDefinition[]).map((c, index) => ({
|
||||
...c,
|
||||
_key: `col-${index}-${Date.now()}`,
|
||||
isAutoIncrement: c.extra && c.extra.toLowerCase().includes('auto_increment')
|
||||
}));
|
||||
setColumns(JSON.parse(JSON.stringify(colsWithKey)));
|
||||
setOriginalColumns(JSON.parse(JSON.stringify(colsWithKey)));
|
||||
} else {
|
||||
message.error("Failed to load columns: " + colsRes.message);
|
||||
}
|
||||
|
||||
if (idxRes.success) setIndexes(idxRes.data);
|
||||
if (fkRes.success) setFks(fkRes.data);
|
||||
if (trigRes.success) setTriggers(trigRes.data);
|
||||
if (ddlRes && ddlRes.success) setDdl(ddlRes.data);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [tab]);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => {
|
||||
setColumns(prev => prev.map(col => {
|
||||
if (col._key === key) {
|
||||
const newCol = { ...col, [field]: value };
|
||||
if (field === 'key' && value === 'PRI') newCol.nullable = 'NO';
|
||||
if (field === 'isAutoIncrement' && value === true) {
|
||||
newCol.key = 'PRI';
|
||||
newCol.nullable = 'NO';
|
||||
newCol.type = 'int'; // Suggest INT
|
||||
}
|
||||
return newCol;
|
||||
}
|
||||
return col;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddColumn = () => {
|
||||
const newCol: EditableColumn = {
|
||||
name: isNewTable ? 'new_column' : `new_col_${columns.length + 1}`,
|
||||
type: 'varchar(255)',
|
||||
nullable: 'YES',
|
||||
key: '',
|
||||
extra: '',
|
||||
comment: '',
|
||||
default: '',
|
||||
_key: `new-${Date.now()}`,
|
||||
isNew: true,
|
||||
isAutoIncrement: false
|
||||
};
|
||||
setColumns([...columns, newCol]);
|
||||
};
|
||||
|
||||
const handleDeleteColumn = (key: string) => {
|
||||
setColumns(prev => prev.filter(c => c._key !== key));
|
||||
};
|
||||
|
||||
const onDragEnd = ({ active, over }: any) => {
|
||||
if (active.id !== over?.id) {
|
||||
setColumns((previous) => {
|
||||
const activeIndex = previous.findIndex((i) => i._key === active.id);
|
||||
const overIndex = previous.findIndex((i) => i._key === over?.id);
|
||||
return arrayMove(previous, activeIndex, overIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const generateDDL = () => {
|
||||
if (isNewTable && !newTableName.trim()) {
|
||||
message.error("请输入表名");
|
||||
return;
|
||||
}
|
||||
if (columns.length === 0) {
|
||||
message.error("请至少添加一个字段");
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``;
|
||||
|
||||
if (isNewTable) {
|
||||
// CREATE TABLE
|
||||
const colDefs = columns.map(curr => {
|
||||
let extra = curr.extra || "";
|
||||
if (curr.isAutoIncrement) {
|
||||
extra += " AUTO_INCREMENT";
|
||||
}
|
||||
return `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
|
||||
});
|
||||
|
||||
const pks = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``);
|
||||
if (pks.length > 0) {
|
||||
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
|
||||
}
|
||||
|
||||
// Append Charset and Collation
|
||||
const sql = `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation};`;
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
} else {
|
||||
// ALTER TABLE (Existing logic)
|
||||
const alters: string[] = [];
|
||||
|
||||
originalColumns.forEach(orig => {
|
||||
if (!columns.find(c => c._key === orig._key)) {
|
||||
alters.push(`DROP COLUMN \`${orig.name}\``);
|
||||
}
|
||||
});
|
||||
|
||||
columns.forEach((curr, index) => {
|
||||
const orig = originalColumns.find(c => c._key === curr._key);
|
||||
const prevCol = index > 0 ? columns[index - 1] : null;
|
||||
const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST';
|
||||
|
||||
let extra = curr.extra || "";
|
||||
if (curr.isAutoIncrement) {
|
||||
if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT";
|
||||
} else {
|
||||
extra = extra.replace(/auto_increment/gi, "").trim();
|
||||
}
|
||||
|
||||
const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
|
||||
|
||||
if (!orig) {
|
||||
alters.push(`ADD COLUMN ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
const origIndex = originalColumns.findIndex(c => c._key === curr._key);
|
||||
const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null;
|
||||
|
||||
let positionChanged = false;
|
||||
if (index === 0 && origIndex !== 0) positionChanged = true;
|
||||
if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true;
|
||||
|
||||
const isNameChanged = orig.name !== curr.name;
|
||||
const isTypeChanged = orig.type !== curr.type;
|
||||
const isNullableChanged = orig.nullable !== curr.nullable;
|
||||
const isDefaultChanged = orig.default !== curr.default;
|
||||
const isCommentChanged = orig.comment !== curr.comment;
|
||||
const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement;
|
||||
|
||||
if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) {
|
||||
if (isNameChanged) {
|
||||
alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k));
|
||||
|
||||
if (keysChanged) {
|
||||
if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`);
|
||||
if (newPKKeys.length > 0) {
|
||||
const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', ');
|
||||
alters.push(`ADD PRIMARY KEY (${pkNames})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alters.length === 0) {
|
||||
message.info("没有检测到变更");
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n");
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const conn = connections.find(c => c.id === tab.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 res = await MySQLQuery(config as any, tab.dbName || '', previewSql);
|
||||
if (res.success) {
|
||||
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
|
||||
setIsPreviewOpen(false);
|
||||
if (!isNewTable) {
|
||||
fetchData();
|
||||
} else {
|
||||
// TODO: Close tab or reload sidebar?
|
||||
// Ideally, refresh sidebar node.
|
||||
}
|
||||
} else {
|
||||
message.error("执行失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Merge columns with resize handler
|
||||
const resizableColumns = tableColumns.map((col, index) => ({
|
||||
...col,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(index),
|
||||
}),
|
||||
}));
|
||||
|
||||
const columnsTabContent = readOnly ? (
|
||||
<Table
|
||||
dataSource={columns}
|
||||
columns={resizableColumns}
|
||||
rowKey="_key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ y: 'calc(100vh - 200px)' }}
|
||||
bordered
|
||||
components={{
|
||||
header: {
|
||||
cell: ResizableTitle,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
|
||||
<Table
|
||||
dataSource={columns}
|
||||
columns={resizableColumns}
|
||||
rowKey="_key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ y: 'calc(100vh - 200px)' }}
|
||||
bordered
|
||||
components={{
|
||||
body: { row: SortableRow },
|
||||
header: { cell: ResizableTitle }
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
value={charset}
|
||||
onChange={v => {
|
||||
setCharset(v);
|
||||
// Set default collation
|
||||
const cols = (COLLATIONS as any)[v];
|
||||
if (cols && cols.length > 0) setCollation(cols[0].value);
|
||||
}}
|
||||
options={CHARSETS}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={collation}
|
||||
onChange={setCollation}
|
||||
options={(COLLATIONS as any)[charset] || []}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
<div style={{ flex: 1 }} />
|
||||
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}>添加字段</Button>}
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
style={{ flex: 1, padding: '0 10px' }}
|
||||
items={[
|
||||
{
|
||||
key: 'columns',
|
||||
label: '字段',
|
||||
children: columnsTabContent
|
||||
},
|
||||
...(!isNewTable ? [
|
||||
{
|
||||
key: 'indexes',
|
||||
label: '索引',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={indexes}
|
||||
columns={[
|
||||
{ title: '名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '字段', dataIndex: 'columnName', key: 'columnName' },
|
||||
{ title: '索引类型', dataIndex: 'indexType', key: 'indexType' },
|
||||
{ title: '唯一', dataIndex: 'nonUnique', key: 'nonUnique', render: (v: number) => v === 0 ? 'Unique' : 'Normal' },
|
||||
]}
|
||||
rowKey={(r) => r.name + r.columnName}
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'foreignKeys',
|
||||
label: '外键',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={fks}
|
||||
columns={[
|
||||
{ title: '名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '字段', dataIndex: 'columnName', key: 'columnName' },
|
||||
{ title: '参考表', dataIndex: 'refTableName', key: 'refTableName' },
|
||||
{ title: '参考字段', dataIndex: 'refColumnName', key: 'refColumnName' },
|
||||
]}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'triggers',
|
||||
label: '触发器',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={triggers}
|
||||
columns={[
|
||||
{ title: '名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '时间', dataIndex: 'timing', key: 'timing' },
|
||||
{ title: '事件', dataIndex: 'event', key: 'event' },
|
||||
{ title: '语句', dataIndex: 'statement', key: 'statement', ellipsis: true },
|
||||
]}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
] : []),
|
||||
...(readOnly ? [{
|
||||
key: 'ddl',
|
||||
label: 'DDL',
|
||||
icon: <FileTextOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'auto', padding: 10, background: '#f5f5f5', border: '1px solid #eee' }}>
|
||||
<pre>{ddl}</pre>
|
||||
</div>
|
||||
)
|
||||
}] : [])
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="确认 SQL 变更"
|
||||
open={isPreviewOpen}
|
||||
onOk={handleExecuteSave}
|
||||
onCancel={() => setIsPreviewOpen(false)}
|
||||
width={700}
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '4px', border: '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
</div>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableDesigner;
|
||||
12
frontend/src/global.d.ts
vendored
Normal file
12
frontend/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
on: (channel: string, listener: (event: any, ...args: any[]) => void) => void;
|
||||
off: (channel: string, listener: (event: any, ...args: any[]) => void) => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
// import './index.css' // Optional global styles
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
81
frontend/src/store.ts
Normal file
81
frontend/src/store.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { SavedConnection, TabData, SavedQuery } from './types';
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
tabs: TabData[];
|
||||
activeTabId: string | null;
|
||||
savedQueries: SavedQuery[];
|
||||
darkMode: boolean;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
|
||||
addTab: (tab: TabData) => void;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
|
||||
saveQuery: (query: SavedQuery) => void;
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
toggleDarkMode: () => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
connections: [],
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
savedQueries: [],
|
||||
darkMode: false,
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
|
||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
|
||||
|
||||
addTab: (tab) => set((state) => {
|
||||
const index = state.tabs.findIndex(t => t.id === tab.id);
|
||||
if (index !== -1) {
|
||||
// Update existing tab with new data (e.g. switch initialTab)
|
||||
const newTabs = [...state.tabs];
|
||||
newTabs[index] = { ...newTabs[index], ...tab };
|
||||
return { tabs: newTabs, activeTabId: tab.id };
|
||||
}
|
||||
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
|
||||
}),
|
||||
|
||||
closeTab: (id) => set((state) => {
|
||||
const newTabs = state.tabs.filter(t => t.id !== id);
|
||||
let newActiveId = state.activeTabId;
|
||||
if (state.activeTabId === id) {
|
||||
newActiveId = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
|
||||
}
|
||||
return { tabs: newTabs, activeTabId: newActiveId };
|
||||
}),
|
||||
|
||||
setActiveTab: (id) => set({ activeTabId: id }),
|
||||
|
||||
saveQuery: (query) => set((state) => {
|
||||
// If query with same ID exists, update it
|
||||
const existing = state.savedQueries.find(q => q.id === query.id);
|
||||
if (existing) {
|
||||
return { savedQueries: state.savedQueries.map(q => q.id === query.id ? query : q) };
|
||||
}
|
||||
return { savedQueries: [...state.savedQueries, query] };
|
||||
}),
|
||||
|
||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
||||
|
||||
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
}),
|
||||
{
|
||||
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
|
||||
}
|
||||
)
|
||||
);
|
||||
86
frontend/src/types.ts
Normal file
86
frontend/src/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export interface SSHConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password?: string;
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
useSSH?: boolean;
|
||||
ssh?: SSHConfig;
|
||||
}
|
||||
|
||||
export interface SavedConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
config: ConnectionConfig;
|
||||
}
|
||||
|
||||
export interface ColumnDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: string;
|
||||
key: string;
|
||||
default?: string;
|
||||
extra: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface IndexDefinition {
|
||||
name: string;
|
||||
columnName: string;
|
||||
nonUnique: number;
|
||||
seqInIndex: number;
|
||||
indexType: string;
|
||||
}
|
||||
|
||||
export interface ForeignKeyDefinition {
|
||||
name: string;
|
||||
columnName: string;
|
||||
refTableName: string;
|
||||
refColumnName: string;
|
||||
constraintName: string;
|
||||
}
|
||||
|
||||
export interface TriggerDefinition {
|
||||
name: string;
|
||||
timing: string;
|
||||
event: string;
|
||||
statement: string;
|
||||
}
|
||||
|
||||
export interface TabData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'query' | 'table' | 'design';
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
query?: string;
|
||||
initialTab?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseNode {
|
||||
title: string;
|
||||
key: string;
|
||||
isLeaf?: boolean;
|
||||
children?: DatabaseNode[];
|
||||
icon?: any;
|
||||
}
|
||||
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
43
frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
43
frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
@@ -0,0 +1,43 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {main} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:main.ConnectionConfig,arg2:string,arg3:string,arg4:main.ChangeSet):Promise<main.QueryResult>;
|
||||
|
||||
export function CreateDatabase(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBConnect(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetAllColumns(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetColumns(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetDatabases(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetForeignKeys(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetIndexes(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetTables(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBGetTriggers(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function DBShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function ExportTable(arg1:main.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<main.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function MySQLConnect(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
||||
|
||||
export function MySQLGetDatabases(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
||||
|
||||
export function MySQLGetTables(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
||||
|
||||
export function MySQLQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function MySQLShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<main.QueryResult>;
|
||||
83
frontend/wailsjs/go/main/App.js
Executable file
83
frontend/wailsjs/go/main/App.js
Executable file
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CreateDatabase(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateDatabase'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DBConnect(arg1) {
|
||||
return window['go']['main']['App']['DBConnect'](arg1);
|
||||
}
|
||||
|
||||
export function DBGetAllColumns(arg1, arg2) {
|
||||
return window['go']['main']['App']['DBGetAllColumns'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DBGetColumns(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBGetColumns'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBGetDatabases(arg1) {
|
||||
return window['go']['main']['App']['DBGetDatabases'](arg1);
|
||||
}
|
||||
|
||||
export function DBGetForeignKeys(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBGetForeignKeys'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBGetIndexes(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBGetIndexes'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBGetTables(arg1, arg2) {
|
||||
return window['go']['main']['App']['DBGetTables'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DBGetTriggers(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBGetTriggers'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBQuery(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBQuery'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExportTable(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ExportTable'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ImportData(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ImportData'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function MySQLConnect(arg1) {
|
||||
return window['go']['main']['App']['MySQLConnect'](arg1);
|
||||
}
|
||||
|
||||
export function MySQLGetDatabases(arg1) {
|
||||
return window['go']['main']['App']['MySQLGetDatabases'](arg1);
|
||||
}
|
||||
|
||||
export function MySQLGetTables(arg1, arg2) {
|
||||
return window['go']['main']['App']['MySQLGetTables'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MySQLQuery(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['MySQLQuery'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['main']['App']['OpenSQLFile']();
|
||||
}
|
||||
136
frontend/wailsjs/go/models.ts
Executable file
136
frontend/wailsjs/go/models.ts
Executable file
@@ -0,0 +1,136 @@
|
||||
export namespace main {
|
||||
|
||||
export class UpdateRow {
|
||||
keys: Record<string, any>;
|
||||
values: Record<string, any>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UpdateRow(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.keys = source["keys"];
|
||||
this.values = source["values"];
|
||||
}
|
||||
}
|
||||
export class ChangeSet {
|
||||
inserts: any[];
|
||||
updates: UpdateRow[];
|
||||
deletes: any[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ChangeSet(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.inserts = source["inserts"];
|
||||
this.updates = this.convertValues(source["updates"], UpdateRow);
|
||||
this.deletes = source["deletes"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SSHConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
keyPath: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SSHConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
this.keyPath = source["keyPath"];
|
||||
}
|
||||
}
|
||||
export class ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
useSSH: boolean;
|
||||
ssh: SSHConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConnectionConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.useSSH = source["useSSH"];
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class QueryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: any;
|
||||
fields?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QueryResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.success = source["success"];
|
||||
this.message = source["message"];
|
||||
this.data = source["data"];
|
||||
this.fields = source["fields"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
249
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
249
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width : number
|
||||
height : number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||
export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||
export function WindowIsMaximised(): Promise<boolean>;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||
export function WindowIsMinimised(): Promise<boolean>;
|
||||
|
||||
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||
export function WindowIsNormal(): Promise<boolean>;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
|
||||
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||
// Returns the current text stored on clipboard
|
||||
export function ClipboardGetText(): Promise<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||
|
||||
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
export function OnFileDropOff() :void
|
||||
|
||||
// Check if the file path resolver is available
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
242
frontend/wailsjs/runtime/runtime.js
Normal file
242
frontend/wailsjs/runtime/runtime.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowIsFullscreen() {
|
||||
return window.runtime.WindowIsFullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowIsMaximised() {
|
||||
return window.runtime.WindowIsMaximised();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function WindowIsMinimised() {
|
||||
return window.runtime.WindowIsMinimised();
|
||||
}
|
||||
|
||||
export function WindowIsNormal() {
|
||||
return window.runtime.WindowIsNormal();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
|
||||
export function ClipboardGetText() {
|
||||
return window.runtime.ClipboardGetText();
|
||||
}
|
||||
|
||||
export function ClipboardSetText(text) {
|
||||
return window.runtime.ClipboardSetText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
*
|
||||
* @export
|
||||
* @callback OnFileDropCallback
|
||||
* @param {number} x - x coordinate of the drop
|
||||
* @param {number} y - y coordinate of the drop
|
||||
* @param {string[]} paths - A list of file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
*
|
||||
* @export
|
||||
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||
*/
|
||||
export function OnFileDrop(callback, useDropTarget) {
|
||||
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
*/
|
||||
export function OnFileDropOff() {
|
||||
return window.runtime.OnFileDropOff();
|
||||
}
|
||||
|
||||
export function CanResolveFilePaths() {
|
||||
return window.runtime.CanResolveFilePaths();
|
||||
}
|
||||
|
||||
export function ResolveFilePaths(files) {
|
||||
return window.runtime.ResolveFilePaths(files);
|
||||
}
|
||||
Reference in New Issue
Block a user