初始化

This commit is contained in:
杨国锋
2026-02-02 09:45:02 +08:00
parent cf06443441
commit e0181cc7ac
40 changed files with 8771 additions and 0 deletions

47
frontend/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1 @@
c1af19c07654ec9f98628c358ae49b1a

25
frontend/src/App.css Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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" }]
}

View 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
View 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
View 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
View 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
View 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"];
}
}
}

View 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
View 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

View 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);
}