first
This commit is contained in:
commit
95f842f6cb
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Local database
|
||||
*.db
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Electron
|
||||
out/
|
||||
*.asar
|
||||
|
||||
184
README.md
Normal file
184
README.md
Normal file
@ -0,0 +1,184 @@
|
||||
# 🚀 EasyShell
|
||||
|
||||
高颜值远程 Shell 管理终端 - 一款现代化的 SSH 连接管理工具
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🎨 **高颜值界面** - 现代化深色主题,精心设计的 UI/UX
|
||||
- 🔐 **SSH 连接管理** - 支持密码和私钥认证方式
|
||||
- 💾 **双模式存储** - 本地 SQLite + 远程 MySQL 同步
|
||||
- 📝 **智能命令提示** - 内置常用命令,支持搜索和使用频率排序
|
||||
- 🔄 **数据同步** - 自动建库建表,一键上传/下载数据
|
||||
- 📑 **多标签终端** - 同时管理多个 SSH 会话
|
||||
- ⌨️ **快捷键支持** - Ctrl+K 打开命令面板
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
应用采用深色主题设计,包含:
|
||||
- 可折叠侧边栏(主机列表分组显示)
|
||||
- 多标签终端区域
|
||||
- 命令面板(Ctrl+K 快速调用)
|
||||
- 主机管理弹窗
|
||||
- 数据库连接设置
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **Electron** - 跨平台桌面应用框架
|
||||
- **React** - 用户界面库
|
||||
- **TailwindCSS** - 原子化 CSS 框架
|
||||
- **Framer Motion** - 动画库
|
||||
- **XTerm.js** - 终端模拟器
|
||||
- **SSH2** - SSH 连接库
|
||||
- **better-sqlite3** - 本地数据库
|
||||
- **mysql2** - MySQL 连接库
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- npm >= 9.0.0
|
||||
- Python 3.x(用于编译原生模块)
|
||||
- Visual Studio Build Tools(Windows)/ Xcode(macOS)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-username/easyshell.git
|
||||
cd easyshell
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 重新编译原生模块(如果遇到问题)
|
||||
npm rebuild better-sqlite3 --build-from-source
|
||||
npm rebuild ssh2 --build-from-source
|
||||
|
||||
# 启动开发模式
|
||||
npm start
|
||||
```
|
||||
|
||||
### 构建发布版本
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run dist
|
||||
```
|
||||
|
||||
## 🚀 使用说明
|
||||
|
||||
### 本地模式
|
||||
|
||||
应用默认使用本地 SQLite 数据库存储数据,无需任何配置即可使用。
|
||||
|
||||
### 远程同步模式
|
||||
|
||||
1. 点击左下角「本地模式」或设置图标
|
||||
2. 输入 MySQL 服务器信息:
|
||||
- 主机地址
|
||||
- 端口(默认 3306)
|
||||
- 用户名
|
||||
- 密码
|
||||
- 数据库名(默认 easyshell)
|
||||
3. 点击「连接数据库」
|
||||
4. 系统将自动创建数据库和所需的表结构
|
||||
|
||||
### 添加主机
|
||||
|
||||
1. 点击侧边栏的 ➕ 按钮或「添加主机」
|
||||
2. 填写主机信息:
|
||||
- 名称(显示名)
|
||||
- 分组(用于分类)
|
||||
- 主机地址
|
||||
- 端口(默认 22)
|
||||
- 用户名
|
||||
- 密码或 SSH 私钥
|
||||
3. 可选择标识颜色
|
||||
4. 点击「测试连接」验证配置
|
||||
5. 点击「添加主机」保存
|
||||
|
||||
### 命令提示
|
||||
|
||||
- 按 `Ctrl+K` 打开命令面板
|
||||
- 搜索或浏览预设命令
|
||||
- 使用方向键选择,回车执行
|
||||
- 命令会直接发送到当前终端
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
easyshell/
|
||||
├── main.js # Electron 主进程
|
||||
├── preload.js # 预加载脚本(IPC 桥接)
|
||||
├── package.json # 项目配置
|
||||
├── tailwind.config.js # TailwindCSS 配置
|
||||
├── public/
|
||||
│ └── index.html # HTML 模板
|
||||
└── src/
|
||||
├── index.js # React 入口
|
||||
├── index.css # 全局样式
|
||||
├── App.js # 主应用组件
|
||||
├── components/
|
||||
│ ├── TitleBar.js # 标题栏
|
||||
│ ├── Sidebar.js # 侧边栏
|
||||
│ ├── Terminal.js # 终端组件
|
||||
│ ├── HostManager.js # 主机管理
|
||||
│ ├── Settings.js # 设置面板
|
||||
│ └── CommandPalette.js # 命令面板
|
||||
└── services/
|
||||
├── database.js # 数据库服务
|
||||
└── ssh.js # SSH 服务
|
||||
```
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Ctrl+K` | 打开命令面板 |
|
||||
| `Esc` | 关闭弹窗 |
|
||||
| `↑/↓` | 命令面板中导航 |
|
||||
| `Enter` | 执行选中命令 |
|
||||
|
||||
## 🔧 数据库结构
|
||||
|
||||
### hosts 表(主机信息)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT | 主键 |
|
||||
| name | VARCHAR | 主机名称 |
|
||||
| host | VARCHAR | 主机地址 |
|
||||
| port | INT | SSH 端口 |
|
||||
| username | VARCHAR | 用户名 |
|
||||
| password | TEXT | 密码(加密存储建议) |
|
||||
| private_key | TEXT | SSH 私钥 |
|
||||
| group_name | VARCHAR | 分组名 |
|
||||
| color | VARCHAR | 标识颜色 |
|
||||
| description | TEXT | 备注 |
|
||||
|
||||
### commands 表(命令提示)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT | 主键 |
|
||||
| command | TEXT | 命令内容 |
|
||||
| description | TEXT | 命令描述 |
|
||||
| category | VARCHAR | 分类 |
|
||||
| usage_count | INT | 使用次数 |
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by EasyShell Team
|
||||
|
||||
228
main.js
Normal file
228
main.js
Normal file
@ -0,0 +1,228 @@
|
||||
/**
|
||||
* EasyShell - Electron 主进程
|
||||
*/
|
||||
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const databaseService = require('./src/services/database');
|
||||
const sshService = require('./src/services/ssh');
|
||||
|
||||
let mainWindow;
|
||||
const isDev = process.env.NODE_ENV !== 'production' || !app.isPackaged;
|
||||
|
||||
// 活动的SSH连接
|
||||
const activeConnections = new Map();
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
frame: false,
|
||||
backgroundColor: '#0a0e14',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
icon: path.join(__dirname, 'public/icon.png'),
|
||||
});
|
||||
|
||||
// 加载应用
|
||||
if (isDev) {
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, 'build/index.html'));
|
||||
}
|
||||
|
||||
// 隐藏菜单栏
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 应用启动
|
||||
app.whenReady().then(async () => {
|
||||
// 初始化本地数据库 (异步)
|
||||
await databaseService.initLocalDatabase();
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 关闭所有SSH连接
|
||||
sshService.disconnectAll();
|
||||
// 关闭数据库
|
||||
databaseService.close();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 窗口控制 IPC ==========
|
||||
|
||||
ipcMain.on('window:minimize', () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('window:close', () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return mainWindow?.isMaximized();
|
||||
});
|
||||
|
||||
// ========== 数据库 IPC ==========
|
||||
|
||||
// MySQL连接
|
||||
ipcMain.handle('db:connectMySQL', async (event, config) => {
|
||||
return await databaseService.connectMySQL(config);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:disconnectMySQL', async () => {
|
||||
return await databaseService.disconnectMySQL();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:isRemoteConnected', () => {
|
||||
return databaseService.isRemoteConnected;
|
||||
});
|
||||
|
||||
// 同步
|
||||
ipcMain.handle('db:syncToRemote', async () => {
|
||||
return await databaseService.syncToRemote();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:syncFromRemote', async () => {
|
||||
return await databaseService.syncFromRemote();
|
||||
});
|
||||
|
||||
// 主机管理
|
||||
ipcMain.handle('hosts:getAll', () => {
|
||||
return databaseService.getAllHosts();
|
||||
});
|
||||
|
||||
ipcMain.handle('hosts:getById', (event, id) => {
|
||||
return databaseService.getHostById(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('hosts:add', (event, host) => {
|
||||
return databaseService.addHost(host);
|
||||
});
|
||||
|
||||
ipcMain.handle('hosts:update', (event, { id, host }) => {
|
||||
return databaseService.updateHost(id, host);
|
||||
});
|
||||
|
||||
ipcMain.handle('hosts:delete', (event, id) => {
|
||||
return databaseService.deleteHost(id);
|
||||
});
|
||||
|
||||
// 命令
|
||||
ipcMain.handle('commands:search', (event, keyword) => {
|
||||
return databaseService.searchCommands(keyword);
|
||||
});
|
||||
|
||||
ipcMain.handle('commands:getAll', () => {
|
||||
return databaseService.getAllCommands();
|
||||
});
|
||||
|
||||
ipcMain.handle('commands:add', (event, command) => {
|
||||
return databaseService.addCommand(command);
|
||||
});
|
||||
|
||||
ipcMain.handle('commands:incrementUsage', (event, id) => {
|
||||
return databaseService.incrementCommandUsage(id);
|
||||
});
|
||||
|
||||
// 命令片段
|
||||
ipcMain.handle('snippets:getAll', () => {
|
||||
return databaseService.getAllSnippets();
|
||||
});
|
||||
|
||||
ipcMain.handle('snippets:add', (event, snippet) => {
|
||||
return databaseService.addSnippet(snippet);
|
||||
});
|
||||
|
||||
ipcMain.handle('snippets:delete', (event, id) => {
|
||||
return databaseService.deleteSnippet(id);
|
||||
});
|
||||
|
||||
// ========== SSH IPC ==========
|
||||
|
||||
ipcMain.handle('ssh:connect', async (event, hostConfig) => {
|
||||
// 预先生成 connectionId
|
||||
const connectionId = `${hostConfig.host}:${hostConfig.port || 22}-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const connection = await sshService.connect(hostConfig, connectionId, {
|
||||
onData: (data) => {
|
||||
mainWindow?.webContents.send(`ssh:data:${connectionId}`, data);
|
||||
},
|
||||
onClose: () => {
|
||||
mainWindow?.webContents.send(`ssh:close:${connectionId}`);
|
||||
activeConnections.delete(connectionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
mainWindow?.webContents.send(`ssh:error:${connectionId}`, error.message);
|
||||
},
|
||||
});
|
||||
|
||||
activeConnections.set(connectionId, connection);
|
||||
|
||||
// 更新最后连接时间
|
||||
if (hostConfig.id) {
|
||||
databaseService.updateLastConnected(hostConfig.id);
|
||||
}
|
||||
|
||||
return { success: true, connectionId: connectionId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('ssh:write', (event, { connectionId, data }) => {
|
||||
const connection = activeConnections.get(connectionId);
|
||||
if (connection) {
|
||||
connection.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('ssh:resize', (event, { connectionId, cols, rows }) => {
|
||||
const connection = activeConnections.get(connectionId);
|
||||
if (connection) {
|
||||
connection.resize(cols, rows);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('ssh:disconnect', (event, connectionId) => {
|
||||
sshService.disconnect(connectionId);
|
||||
activeConnections.delete(connectionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('ssh:test', async (event, hostConfig) => {
|
||||
return await sshService.testConnection(hostConfig);
|
||||
});
|
||||
|
||||
ipcMain.handle('ssh:exec', async (event, { hostConfig, command }) => {
|
||||
return await sshService.exec(hostConfig, command);
|
||||
});
|
||||
|
||||
20937
package-lock.json
generated
Normal file
20937
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "easyshell",
|
||||
"version": "1.0.0",
|
||||
"description": "高颜值远程Shell管理终端",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "concurrently \"npm run react\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"react": "cross-env BROWSER=none react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"electron": "electron .",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.easyshell.app",
|
||||
"productName": "EasyShell",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/services/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.1.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
"mysql2": "^3.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sql.js": "^1.10.0",
|
||||
"ssh2": "^1.15.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"last 1 electron version"
|
||||
],
|
||||
"development": [
|
||||
"last 1 electron version"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
76
preload.js
Normal file
76
preload.js
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* EasyShell - 预加载脚本
|
||||
* 安全地暴露Node.js API给渲染进程
|
||||
*/
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 窗口控制
|
||||
window: {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
},
|
||||
|
||||
// 数据库操作
|
||||
db: {
|
||||
connectMySQL: (config) => ipcRenderer.invoke('db:connectMySQL', config),
|
||||
disconnectMySQL: () => ipcRenderer.invoke('db:disconnectMySQL'),
|
||||
isRemoteConnected: () => ipcRenderer.invoke('db:isRemoteConnected'),
|
||||
syncToRemote: () => ipcRenderer.invoke('db:syncToRemote'),
|
||||
syncFromRemote: () => ipcRenderer.invoke('db:syncFromRemote'),
|
||||
},
|
||||
|
||||
// 主机管理
|
||||
hosts: {
|
||||
getAll: () => ipcRenderer.invoke('hosts:getAll'),
|
||||
getById: (id) => ipcRenderer.invoke('hosts:getById', id),
|
||||
add: (host) => ipcRenderer.invoke('hosts:add', host),
|
||||
update: (id, host) => ipcRenderer.invoke('hosts:update', { id, host }),
|
||||
delete: (id) => ipcRenderer.invoke('hosts:delete', id),
|
||||
},
|
||||
|
||||
// 命令
|
||||
commands: {
|
||||
search: (keyword) => ipcRenderer.invoke('commands:search', keyword),
|
||||
getAll: () => ipcRenderer.invoke('commands:getAll'),
|
||||
add: (command) => ipcRenderer.invoke('commands:add', command),
|
||||
incrementUsage: (id) => ipcRenderer.invoke('commands:incrementUsage', id),
|
||||
},
|
||||
|
||||
// 命令片段
|
||||
snippets: {
|
||||
getAll: () => ipcRenderer.invoke('snippets:getAll'),
|
||||
add: (snippet) => ipcRenderer.invoke('snippets:add', snippet),
|
||||
delete: (id) => ipcRenderer.invoke('snippets:delete', id),
|
||||
},
|
||||
|
||||
// SSH
|
||||
ssh: {
|
||||
connect: (hostConfig) => ipcRenderer.invoke('ssh:connect', hostConfig),
|
||||
write: (connectionId, data) => ipcRenderer.send('ssh:write', { connectionId, data }),
|
||||
resize: (connectionId, cols, rows) => ipcRenderer.send('ssh:resize', { connectionId, cols, rows }),
|
||||
disconnect: (connectionId) => ipcRenderer.send('ssh:disconnect', connectionId),
|
||||
test: (hostConfig) => ipcRenderer.invoke('ssh:test', hostConfig),
|
||||
exec: (hostConfig, command) => ipcRenderer.invoke('ssh:exec', { hostConfig, command }),
|
||||
|
||||
// 事件监听
|
||||
onData: (connectionId, callback) => {
|
||||
const channel = `ssh:data:${connectionId}`;
|
||||
ipcRenderer.on(channel, (event, data) => callback(data));
|
||||
return () => ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
onClose: (connectionId, callback) => {
|
||||
const channel = `ssh:close:${connectionId}`;
|
||||
ipcRenderer.on(channel, () => callback());
|
||||
return () => ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
onError: (connectionId, callback) => {
|
||||
const channel = `ssh:error:${connectionId}`;
|
||||
ipcRenderer.on(channel, (event, error) => callback(error));
|
||||
return () => ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
43
public/index.html
Normal file
43
public/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<meta name="description" content="EasyShell - 高颜值远程Shell管理终端" />
|
||||
<title>EasyShell</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #0a0e14;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>您需要启用 JavaScript 才能运行此应用。</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
281
src/App.js
Normal file
281
src/App.js
Normal file
@ -0,0 +1,281 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Terminal from './components/Terminal';
|
||||
import HostManager from './components/HostManager';
|
||||
import Settings from './components/Settings';
|
||||
import CommandPalette from './components/CommandPalette';
|
||||
|
||||
function App() {
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [activeTabs, setActiveTabs] = useState([]);
|
||||
const [activeTabId, setActiveTabId] = useState(null);
|
||||
const [showHostManager, setShowHostManager] = useState(false);
|
||||
const [editingHost, setEditingHost] = useState(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// 加载主机列表
|
||||
const loadHosts = useCallback(async () => {
|
||||
if (window.electronAPI) {
|
||||
const hostList = await window.electronAPI.hosts.getAll();
|
||||
setHosts(hostList);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查远程连接状态
|
||||
const checkRemoteStatus = useCallback(async () => {
|
||||
if (window.electronAPI) {
|
||||
const connected = await window.electronAPI.db.isRemoteConnected();
|
||||
setIsRemoteConnected(connected);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadHosts();
|
||||
checkRemoteStatus();
|
||||
}, [loadHosts, checkRemoteStatus]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette(true);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowCommandPalette(false);
|
||||
setShowHostManager(false);
|
||||
setShowSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// 连接主机
|
||||
const connectHost = useCallback((host) => {
|
||||
const tabId = `terminal-${host.id}-${Date.now()}`;
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
hostId: host.id,
|
||||
title: host.name,
|
||||
host: host.host,
|
||||
type: 'terminal',
|
||||
connected: false,
|
||||
};
|
||||
|
||||
setActiveTabs((prev) => [...prev, newTab]);
|
||||
setActiveTabId(tabId);
|
||||
setShowHostManager(false);
|
||||
}, []);
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = useCallback((tabId) => {
|
||||
setActiveTabs((prev) => {
|
||||
const newTabs = prev.filter((t) => t.id !== tabId);
|
||||
return newTabs;
|
||||
});
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId === tabId) {
|
||||
const remainingTabs = activeTabs.filter((t) => t.id !== tabId);
|
||||
return remainingTabs.length > 0 ? remainingTabs[remainingTabs.length - 1].id : null;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
}, [activeTabs]);
|
||||
|
||||
// 更新连接状态
|
||||
const handleConnectionChange = useCallback((tabId, connected) => {
|
||||
setActiveTabs((prev) =>
|
||||
prev.map((t) => (t.id === tabId ? { ...t, connected } : t))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 处理主机更新
|
||||
const handleHostsUpdate = useCallback(() => {
|
||||
loadHosts();
|
||||
}, [loadHosts]);
|
||||
|
||||
// 编辑主机
|
||||
const handleEditHost = useCallback((host) => {
|
||||
setEditingHost(host);
|
||||
setShowHostManager(true);
|
||||
}, []);
|
||||
|
||||
const openHostManager = useCallback(() => {
|
||||
setEditingHost(null);
|
||||
setShowHostManager(true);
|
||||
}, []);
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
setShowSettings(true);
|
||||
}, []);
|
||||
|
||||
const openCommandPalette = useCallback(() => {
|
||||
setShowCommandPalette(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col gradient-bg">
|
||||
<TitleBar />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
hosts={hosts}
|
||||
activeTabs={activeTabs}
|
||||
activeTabId={activeTabId}
|
||||
onSelectTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onConnectHost={connectHost}
|
||||
onOpenHostManager={openHostManager}
|
||||
onEditHost={handleEditHost}
|
||||
onOpenSettings={openSettings}
|
||||
isRemoteConnected={isRemoteConnected}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 标签栏 */}
|
||||
{activeTabs.length > 0 && (
|
||||
<div className="h-10 bg-shell-surface/50 border-b border-shell-border flex items-center px-2 gap-1 overflow-x-auto custom-scrollbar flex-shrink-0">
|
||||
{activeTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer
|
||||
transition-all duration-200 group min-w-0 flex-shrink-0
|
||||
${activeTabId === tab.id
|
||||
? 'bg-shell-accent/20 text-shell-accent border border-shell-accent/30'
|
||||
: 'hover:bg-shell-card text-shell-text-dim hover:text-shell-text border border-transparent'
|
||||
}
|
||||
`}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
tab.connected ? 'status-online' : 'status-offline'
|
||||
}`} />
|
||||
<span className="truncate text-sm font-medium max-w-[120px]">
|
||||
{tab.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-shell-error transition-opacity ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 终端内容 - 所有终端都渲染,通过显示/隐藏切换 */}
|
||||
<div className="flex-1 relative">
|
||||
{activeTabs.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-6 opacity-20">🚀</div>
|
||||
<h2 className="text-2xl font-bold text-shell-text mb-3">
|
||||
欢迎使用 EasyShell
|
||||
</h2>
|
||||
<p className="text-shell-text-dim mb-6">
|
||||
高颜值远程 Shell 管理终端
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={openHostManager}
|
||||
className="px-6 py-3 bg-shell-accent/20 border border-shell-accent/50
|
||||
rounded-lg text-shell-accent hover:bg-shell-accent/30
|
||||
transition-all btn-glow font-medium"
|
||||
>
|
||||
添加主机
|
||||
</button>
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="px-6 py-3 bg-shell-card border border-shell-border
|
||||
rounded-lg text-shell-text-dim hover:text-shell-text
|
||||
hover:border-shell-accent/30 transition-all font-medium"
|
||||
>
|
||||
连接数据库
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-shell-text-dim text-sm mt-8">
|
||||
按 <kbd className="code-highlight">Ctrl+K</kbd> 打开命令面板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
activeTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="absolute inset-0"
|
||||
style={{ display: activeTabId === tab.id ? 'block' : 'none' }}
|
||||
>
|
||||
<Terminal
|
||||
tabId={tab.id}
|
||||
hostId={tab.hostId}
|
||||
onConnectionChange={(connected) => handleConnectionChange(tab.id, connected)}
|
||||
onShowCommandPalette={openCommandPalette}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
<AnimatePresence>
|
||||
{showHostManager && (
|
||||
<HostManager
|
||||
hosts={hosts}
|
||||
initialEditHost={editingHost}
|
||||
onClose={() => { setShowHostManager(false); setEditingHost(null); }}
|
||||
onConnect={connectHost}
|
||||
onUpdate={handleHostsUpdate}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSettings && (
|
||||
<Settings
|
||||
onClose={() => setShowSettings(false)}
|
||||
isRemoteConnected={isRemoteConnected}
|
||||
onConnectionChange={(connected) => {
|
||||
setIsRemoteConnected(connected);
|
||||
if (connected) loadHosts();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showCommandPalette && (
|
||||
<CommandPalette
|
||||
onClose={() => setShowCommandPalette(false)}
|
||||
onSelectCommand={(cmd) => {
|
||||
if (activeTabId) {
|
||||
const event = new CustomEvent('terminal-command', {
|
||||
detail: { tabId: activeTabId, command: cmd },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
setShowCommandPalette(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
220
src/components/CommandPalette.js
Normal file
220
src/components/CommandPalette.js
Normal file
@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiSearch, FiCommand, FiClock, FiTag } from 'react-icons/fi';
|
||||
|
||||
function CommandPalette({ onClose, onSelectCommand }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [commands, setCommands] = useState([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
// 加载命令列表
|
||||
useEffect(() => {
|
||||
const loadCommands = async () => {
|
||||
if (window.electronAPI) {
|
||||
const allCommands = await window.electronAPI.commands.getAll();
|
||||
setCommands(allCommands);
|
||||
setFilteredCommands(allCommands.slice(0, 15));
|
||||
}
|
||||
};
|
||||
loadCommands();
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 搜索命令
|
||||
useEffect(() => {
|
||||
const search = async () => {
|
||||
if (searchTerm.trim()) {
|
||||
if (window.electronAPI) {
|
||||
const results = await window.electronAPI.commands.search(searchTerm);
|
||||
setFilteredCommands(results);
|
||||
}
|
||||
} else {
|
||||
setFilteredCommands(commands.slice(0, 15));
|
||||
}
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
search();
|
||||
}, [searchTerm, commands]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < filteredCommands.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (filteredCommands[selectedIndex]) {
|
||||
handleSelect(filteredCommands[selectedIndex]);
|
||||
} else if (searchTerm.trim()) {
|
||||
// 如果没有匹配项,直接发送输入的命令
|
||||
onSelectCommand(searchTerm.trim());
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filteredCommands, selectedIndex, searchTerm, onClose, onSelectCommand]);
|
||||
|
||||
// 滚动到选中项
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleSelect = async (command) => {
|
||||
if (window.electronAPI && command.id) {
|
||||
await window.electronAPI.commands.incrementUsage(command.id);
|
||||
}
|
||||
onSelectCommand(command.command);
|
||||
};
|
||||
|
||||
// 按分类分组命令
|
||||
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
|
||||
const category = cmd.category || '通用';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(cmd);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let flatIndex = 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-start justify-center z-50 pt-[15vh]"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: -20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: -20 }}
|
||||
className="command-hint w-full max-w-2xl rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="p-4 border-b border-shell-border">
|
||||
<div className="relative">
|
||||
<FiSearch className="absolute left-4 top-1/2 -translate-y-1/2 text-shell-text-dim" size={20} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim text-lg
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="搜索命令或直接输入..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-shell-text-dim">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-shell-card rounded border border-shell-border">↑↓</kbd>
|
||||
导航
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-shell-card rounded border border-shell-border">Enter</kbd>
|
||||
执行
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-shell-card rounded border border-shell-border">Esc</kbd>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 命令列表 */}
|
||||
<div ref={listRef} className="max-h-[50vh] overflow-y-auto custom-scrollbar">
|
||||
{Object.entries(groupedCommands).map(([category, categoryCommands]) => (
|
||||
<div key={category}>
|
||||
<div className="px-4 py-2 text-xs font-medium text-shell-text-dim uppercase tracking-wider bg-shell-bg/50 sticky top-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<FiTag size={12} />
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
{categoryCommands.map((cmd) => {
|
||||
const currentIndex = flatIndex++;
|
||||
return (
|
||||
<div
|
||||
key={cmd.id || cmd.command}
|
||||
onClick={() => handleSelect(cmd)}
|
||||
className={`
|
||||
px-4 py-3 cursor-pointer flex items-center gap-4 transition-all
|
||||
${currentIndex === selectedIndex
|
||||
? 'bg-shell-accent/20 border-l-2 border-shell-accent'
|
||||
: 'hover:bg-shell-card border-l-2 border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-shell-card flex items-center justify-center flex-shrink-0">
|
||||
<FiCommand className="text-shell-accent" size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-shell-text text-sm truncate">
|
||||
{cmd.command}
|
||||
</div>
|
||||
{cmd.description && (
|
||||
<div className="text-xs text-shell-text-dim truncate mt-0.5">
|
||||
{cmd.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cmd.usage_count > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-shell-text-dim">
|
||||
<FiClock size={12} />
|
||||
<span>{cmd.usage_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="p-8 text-center text-shell-text-dim">
|
||||
<FiCommand className="mx-auto text-3xl mb-3 opacity-50" />
|
||||
<p>没有找到匹配的命令</p>
|
||||
<p className="text-sm mt-1">
|
||||
按 <kbd className="code-highlight">Enter</kbd> 执行输入的命令
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-3 border-t border-shell-border bg-shell-bg/50 text-xs text-shell-text-dim">
|
||||
<span className="flex items-center gap-2">
|
||||
<FiCommand size={12} />
|
||||
提示:直接输入命令并按 Enter 可快速执行
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandPalette;
|
||||
|
||||
477
src/components/HostManager.js
Normal file
477
src/components/HostManager.js
Normal file
@ -0,0 +1,477 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FiX,
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiServer,
|
||||
FiCheck,
|
||||
FiLoader,
|
||||
FiKey,
|
||||
FiEye,
|
||||
FiEyeOff,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const colors = [
|
||||
'#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff',
|
||||
'#56d4dd', '#ffa657', '#ff7b72', '#d2a8ff', '#76e3ea',
|
||||
];
|
||||
|
||||
function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
const [isEditing, setIsEditing] = useState(!!initialEditHost);
|
||||
const [editingHost, setEditingHost] = useState(initialEditHost || null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
groupName: '默认分组',
|
||||
color: '#58a6ff',
|
||||
description: '',
|
||||
});
|
||||
|
||||
// 初始化编辑状态
|
||||
useEffect(() => {
|
||||
if (initialEditHost) {
|
||||
setEditingHost(initialEditHost);
|
||||
setIsEditing(true);
|
||||
}
|
||||
}, [initialEditHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
setFormData({
|
||||
name: editingHost.name || '',
|
||||
host: editingHost.host || '',
|
||||
port: editingHost.port || 22,
|
||||
username: editingHost.username || '',
|
||||
password: editingHost.password || '',
|
||||
privateKey: editingHost.private_key || '',
|
||||
groupName: editingHost.group_name || '默认分组',
|
||||
color: editingHost.color || '#58a6ff',
|
||||
description: editingHost.description || '',
|
||||
});
|
||||
}
|
||||
}, [editingHost]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
groupName: '默认分组',
|
||||
color: '#58a6ff',
|
||||
description: '',
|
||||
});
|
||||
setEditingHost(null);
|
||||
setIsEditing(false);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
try {
|
||||
if (editingHost) {
|
||||
await window.electronAPI.hosts.update(editingHost.id, formData);
|
||||
} else {
|
||||
await window.electronAPI.hosts.add(formData);
|
||||
}
|
||||
onUpdate();
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.electronAPI) return;
|
||||
if (window.confirm('确定要删除这个主机吗?')) {
|
||||
await window.electronAPI.hosts.delete(id);
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.ssh.test({
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
privateKey: formData.privateKey,
|
||||
});
|
||||
setTestResult(result);
|
||||
} catch (error) {
|
||||
setTestResult({ success: false, message: error.message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-shell-surface border border-shell-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="px-6 py-4 border-b border-shell-border flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-shell-text">
|
||||
{isEditing ? (editingHost ? '编辑主机' : '添加主机') : '主机管理'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(85vh-130px)]">
|
||||
{/* 主机列表 */}
|
||||
<div className="w-80 border-r border-shell-border overflow-y-auto custom-scrollbar">
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-shell-accent/20 border border-shell-accent/30 rounded-lg
|
||||
text-shell-accent hover:bg-shell-accent/30 transition-all btn-glow"
|
||||
>
|
||||
<FiPlus size={18} />
|
||||
<span>添加新主机</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="group p-3 rounded-lg border border-shell-border hover:border-shell-accent/30
|
||||
bg-shell-card/50 hover:bg-shell-card transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${host.color}20` }}
|
||||
>
|
||||
<FiServer size={18} style={{ color: host.color }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-shell-text truncate">
|
||||
{host.name}
|
||||
</div>
|
||||
<div className="text-xs text-shell-text-dim truncate">
|
||||
{host.username}@{host.host}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-shell-border">
|
||||
<button
|
||||
onClick={() => onConnect(host)}
|
||||
className="flex-1 px-3 py-1.5 bg-shell-accent/20 text-shell-accent text-sm
|
||||
rounded-md hover:bg-shell-accent/30 transition-colors"
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingHost(host);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-shell-border text-shell-text-dim
|
||||
hover:text-shell-text transition-colors"
|
||||
>
|
||||
<FiEdit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(host.id)}
|
||||
className="p-1.5 rounded-md hover:bg-shell-error/20 text-shell-text-dim
|
||||
hover:text-shell-error transition-colors"
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hosts.length === 0 && (
|
||||
<div className="text-center py-8 text-shell-text-dim">
|
||||
暂无主机,点击上方按钮添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="生产服务器"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分组 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
分组
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="默认分组"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 主机 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
主机地址 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="192.168.1.100 或 example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 端口 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 22 })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户名 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
用户名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-2.5 pr-12 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-shell-text-dim
|
||||
hover:text-shell-text transition-colors"
|
||||
>
|
||||
{showPassword ? <FiEyeOff size={18} /> : <FiEye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 私钥 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<FiKey size={14} />
|
||||
SSH 私钥 (可选)
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.privateKey}
|
||||
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50 font-mono text-sm
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 resize-none"
|
||||
placeholder="-----BEGIN RSA PRIVATE KEY-----..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
标识颜色
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color })}
|
||||
className={`w-8 h-8 rounded-lg transition-all ${
|
||||
formData.color === color
|
||||
? 'ring-2 ring-offset-2 ring-offset-shell-surface ring-white/50 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
备注说明
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 resize-none"
|
||||
placeholder="关于这台服务器的备注..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 测试结果 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-4 rounded-lg border ${
|
||||
testResult.success
|
||||
? 'bg-shell-success/10 border-shell-success/30 text-shell-success'
|
||||
: 'bg-shell-error/10 border-shell-error/30 text-shell-error'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? <FiCheck size={18} /> : <FiX size={18} />}
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-shell-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !formData.host || !formData.username}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-shell-card border border-shell-border
|
||||
rounded-lg text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{testing ? (
|
||||
<FiLoader className="animate-spin" size={16} />
|
||||
) : (
|
||||
<FiCheck size={16} />
|
||||
)}
|
||||
<span>测试连接</span>
|
||||
</button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2 border border-shell-border rounded-lg text-shell-text-dim
|
||||
hover:text-shell-text hover:bg-shell-card transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-shell-accent rounded-lg text-white font-medium
|
||||
hover:bg-shell-accent/80 transition-all btn-glow"
|
||||
>
|
||||
{editingHost ? '保存修改' : '添加主机'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-shell-text-dim">
|
||||
<div className="text-center">
|
||||
<FiServer className="mx-auto text-5xl mb-4 opacity-30" />
|
||||
<p>选择一个主机进行编辑</p>
|
||||
<p className="text-sm mt-1">或点击"添加新主机"创建</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostManager;
|
||||
|
||||
361
src/components/Settings.js
Normal file
361
src/components/Settings.js
Normal file
@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FiX,
|
||||
FiDatabase,
|
||||
FiCloud,
|
||||
FiCloudOff,
|
||||
FiRefreshCw,
|
||||
FiDownload,
|
||||
FiUpload,
|
||||
FiCheck,
|
||||
FiLoader,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
function Settings({ onClose, isRemoteConnected, onConnectionChange }) {
|
||||
const [activeTab, setActiveTab] = useState('database');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [mysqlConfig, setMysqlConfig] = useState({
|
||||
host: '',
|
||||
port: 3306,
|
||||
user: '',
|
||||
password: '',
|
||||
database: 'easyshell',
|
||||
});
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setConnecting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.db.connectMySQL(mysqlConfig);
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: '数据库连接成功!已自动创建数据库和表结构' });
|
||||
onConnectionChange(true);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: `连接失败: ${result.error}` });
|
||||
onConnectionChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: `连接失败: ${error.message}` });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
await window.electronAPI.db.disconnectMySQL();
|
||||
onConnectionChange(false);
|
||||
setMessage({ type: 'success', text: '已断开远程数据库连接' });
|
||||
};
|
||||
|
||||
const handleSyncToRemote = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setSyncing(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.db.syncToRemote();
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: `同步成功!已上传 ${result.synced} 条主机信息` });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: `同步失败: ${result.error}` });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: `同步失败: ${error.message}` });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromRemote = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setSyncing(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.db.syncFromRemote();
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `同步成功!已下载 ${result.hosts} 条主机信息和 ${result.commands} 条命令`
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: 'error', text: `同步失败: ${result.error}` });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: `同步失败: ${error.message}` });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-shell-surface border border-shell-border rounded-xl shadow-2xl w-full max-w-2xl"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="px-6 py-4 border-b border-shell-border flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-shell-text">设置</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="px-6 pt-4 border-b border-shell-border">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('database')}
|
||||
className={`pb-3 px-2 border-b-2 transition-colors ${
|
||||
activeTab === 'database'
|
||||
? 'border-shell-accent text-shell-accent'
|
||||
: 'border-transparent text-shell-text-dim hover:text-shell-text'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FiDatabase size={16} />
|
||||
数据库同步
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'database' && (
|
||||
<div className="space-y-6">
|
||||
{/* 连接状态 */}
|
||||
<div className={`p-4 rounded-lg border flex items-center justify-between ${
|
||||
isRemoteConnected
|
||||
? 'bg-shell-success/10 border-shell-success/30'
|
||||
: 'bg-shell-card border-shell-border'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{isRemoteConnected ? (
|
||||
<FiCloud className="text-shell-success" size={24} />
|
||||
) : (
|
||||
<FiCloudOff className="text-shell-text-dim" size={24} />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-shell-text">
|
||||
{isRemoteConnected ? '已连接远程数据库' : '本地离线模式'}
|
||||
</div>
|
||||
<div className="text-sm text-shell-text-dim">
|
||||
{isRemoteConnected
|
||||
? '数据将自动同步到MySQL服务器'
|
||||
: '数据仅保存在本地SQLite数据库'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRemoteConnected && (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="px-3 py-1.5 border border-shell-error/50 text-shell-error
|
||||
rounded-md hover:bg-shell-error/10 transition-colors text-sm"
|
||||
>
|
||||
断开连接
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MySQL配置表单 */}
|
||||
{!isRemoteConnected && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-shell-text">MySQL 连接配置</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
主机地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mysqlConfig.host}
|
||||
onChange={(e) => setMysqlConfig({ ...mysqlConfig, host: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="localhost 或 IP地址"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={mysqlConfig.port}
|
||||
onChange={(e) => setMysqlConfig({ ...mysqlConfig, port: parseInt(e.target.value) || 3306 })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mysqlConfig.user}
|
||||
onChange={(e) => setMysqlConfig({ ...mysqlConfig, user: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={mysqlConfig.password}
|
||||
onChange={(e) => setMysqlConfig({ ...mysqlConfig, password: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-shell-text-dim mb-2">
|
||||
数据库名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mysqlConfig.database}
|
||||
onChange={(e) => setMysqlConfig({ ...mysqlConfig, database: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
|
||||
text-shell-text placeholder-shell-text-dim/50
|
||||
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
|
||||
placeholder="easyshell"
|
||||
/>
|
||||
<p className="text-xs text-shell-text-dim mt-1">
|
||||
如果数据库不存在,将自动创建
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !mysqlConfig.host || !mysqlConfig.user}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-shell-accent rounded-lg text-white font-medium
|
||||
hover:bg-shell-accent/80 disabled:opacity-50
|
||||
disabled:cursor-not-allowed transition-all btn-glow"
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<FiLoader className="animate-spin" size={18} />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiDatabase size={18} />
|
||||
连接数据库
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 同步操作 */}
|
||||
{isRemoteConnected && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-shell-text">数据同步</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={handleSyncToRemote}
|
||||
disabled={syncing}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-shell-card border border-shell-border rounded-lg
|
||||
hover:border-shell-accent/50 hover:bg-shell-card/80
|
||||
disabled:opacity-50 transition-all"
|
||||
>
|
||||
{syncing ? (
|
||||
<FiLoader className="animate-spin" size={18} />
|
||||
) : (
|
||||
<FiUpload size={18} />
|
||||
)}
|
||||
<span>上传到远程</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSyncFromRemote}
|
||||
disabled={syncing}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-shell-card border border-shell-border rounded-lg
|
||||
hover:border-shell-accent/50 hover:bg-shell-card/80
|
||||
disabled:opacity-50 transition-all"
|
||||
>
|
||||
{syncing ? (
|
||||
<FiLoader className="animate-spin" size={18} />
|
||||
) : (
|
||||
<FiDownload size={18} />
|
||||
)}
|
||||
<span>从远程下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border flex items-start gap-3 ${
|
||||
message.type === 'success'
|
||||
? 'bg-shell-success/10 border-shell-success/30 text-shell-success'
|
||||
: 'bg-shell-error/10 border-shell-error/30 text-shell-error'
|
||||
}`}
|
||||
>
|
||||
{message.type === 'success' ? (
|
||||
<FiCheck size={20} className="flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<FiAlertCircle size={20} className="flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span>{message.text}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
|
||||
228
src/components/Sidebar.js
Normal file
228
src/components/Sidebar.js
Normal file
@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FiServer,
|
||||
FiPlus,
|
||||
FiSettings,
|
||||
FiCloud,
|
||||
FiCloudOff,
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiTerminal,
|
||||
FiEdit2,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
function Sidebar({
|
||||
hosts,
|
||||
activeTabs,
|
||||
activeTabId,
|
||||
onSelectTab,
|
||||
onCloseTab,
|
||||
onConnectHost,
|
||||
onOpenHostManager,
|
||||
onEditHost,
|
||||
onOpenSettings,
|
||||
isRemoteConnected,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) {
|
||||
// 按分组组织主机
|
||||
const groupedHosts = hosts.reduce((acc, host) => {
|
||||
const group = host.group_name || '默认分组';
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(host);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: collapsed ? 56 : 260 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-shell-surface/50 border-r border-shell-border flex flex-col h-full overflow-hidden"
|
||||
>
|
||||
{/* 顶部操作区 */}
|
||||
<div className="p-3 border-b border-shell-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm font-semibold text-shell-text"
|
||||
>
|
||||
主机列表
|
||||
</motion.span>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 ${collapsed ? 'flex-col' : ''}`}>
|
||||
<button
|
||||
onClick={onOpenHostManager}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-accent
|
||||
transition-colors"
|
||||
title="添加主机"
|
||||
>
|
||||
<FiPlus size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors"
|
||||
title={collapsed ? '展开' : '收起'}
|
||||
>
|
||||
{collapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主机列表 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
|
||||
<div key={groupName} className="mb-4">
|
||||
{!collapsed && (
|
||||
<div className="px-2 py-1 text-xs font-medium text-shell-text-dim uppercase tracking-wider">
|
||||
{groupName}
|
||||
</div>
|
||||
)}
|
||||
{groupHosts.map((host) => {
|
||||
const isActive = activeTabs.some((t) => t.hostId === host.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={host.id}
|
||||
whileHover={{ x: collapsed ? 0 : 2 }}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer mb-1
|
||||
transition-all duration-200 group relative
|
||||
${isActive
|
||||
? 'bg-shell-accent/15 border border-shell-accent/30'
|
||||
: 'hover:bg-shell-card border border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* 点击连接 */}
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0"
|
||||
onClick={() => onConnectHost(host)}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${host.color}20` }}
|
||||
>
|
||||
<FiServer size={16} style={{ color: host.color }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-shell-text truncate">
|
||||
{host.name}
|
||||
</div>
|
||||
<div className="text-xs text-shell-text-dim truncate">
|
||||
{host.username}@{host.host}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost && onEditHost(host);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md
|
||||
hover:bg-shell-border text-shell-text-dim hover:text-shell-text
|
||||
transition-all flex-shrink-0"
|
||||
title="编辑主机"
|
||||
>
|
||||
<FiEdit2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!collapsed && isActive && (
|
||||
<div className="w-2 h-2 rounded-full status-online flex-shrink-0" />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hosts.length === 0 && !collapsed && (
|
||||
<div className="text-center py-8">
|
||||
<FiTerminal className="mx-auto text-3xl text-shell-text-dim mb-3 opacity-50" />
|
||||
<p className="text-sm text-shell-text-dim">暂无主机</p>
|
||||
<button
|
||||
onClick={onOpenHostManager}
|
||||
className="mt-3 text-sm text-shell-accent hover:underline"
|
||||
>
|
||||
添加第一个主机
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 折叠状态下的空状态提示 */}
|
||||
{hosts.length === 0 && collapsed && (
|
||||
<div className="text-center py-4">
|
||||
<button
|
||||
onClick={onOpenHostManager}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim"
|
||||
title="添加主机"
|
||||
>
|
||||
<FiTerminal size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<div className="p-3 border-t border-shell-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? 'flex-col gap-2' : 'justify-between'}`}>
|
||||
{/* 数据库连接状态 */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer
|
||||
transition-colors ${collapsed ? 'justify-center w-full' : ''}
|
||||
${isRemoteConnected
|
||||
? 'bg-shell-success/10 text-shell-success'
|
||||
: 'bg-shell-card text-shell-text-dim hover:text-shell-text'
|
||||
}
|
||||
`}
|
||||
onClick={onOpenSettings}
|
||||
title={isRemoteConnected ? '已连接远程数据库' : '未连接远程数据库'}
|
||||
>
|
||||
{isRemoteConnected ? <FiCloud size={16} /> : <FiCloudOff size={16} />}
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-medium">
|
||||
{isRemoteConnected ? '已同步' : '本地模式'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors"
|
||||
title="设置"
|
||||
>
|
||||
<FiSettings size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 折叠状态下的设置按钮 */}
|
||||
{collapsed && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors w-full flex justify-center"
|
||||
title="设置"
|
||||
>
|
||||
<FiSettings size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
380
src/components/Terminal.js
Normal file
380
src/components/Terminal.js
Normal file
@ -0,0 +1,380 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FiCommand, FiRefreshCw } from 'react-icons/fi';
|
||||
|
||||
function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
|
||||
const containerRef = useRef(null);
|
||||
const terminalRef = useRef(null);
|
||||
const xtermRef = useRef(null);
|
||||
const fitAddonRef = useRef(null);
|
||||
const connectionIdRef = useRef(null);
|
||||
const cleanupListenersRef = useRef(null);
|
||||
const isConnectingRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const initTimerRef = useRef(null);
|
||||
const hasConnectedRef = useRef(false); // 防止重复连接
|
||||
const resizeObserverRef = useRef(null);
|
||||
|
||||
// 用 ref 存储回调,避免作为依赖
|
||||
const onConnectionChangeRef = useRef(onConnectionChange);
|
||||
onConnectionChangeRef.current = onConnectionChange;
|
||||
|
||||
const [connectionId, setConnectionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// 连接SSH - 不依赖 onConnectionChange
|
||||
const connect = useCallback(async () => {
|
||||
// 防止重复连接
|
||||
if (!window.electronAPI || !hostId || isConnectingRef.current || connectionIdRef.current || hasConnectedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasConnectedRef.current = true;
|
||||
isConnectingRef.current = true;
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const host = await window.electronAPI.hosts.getById(hostId);
|
||||
if (!host) {
|
||||
throw new Error('主机信息不存在');
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.ssh.connect({
|
||||
id: host.id,
|
||||
host: host.host,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
privateKey: host.private_key,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (result.success) {
|
||||
connectionIdRef.current = result.connectionId;
|
||||
setConnectionId(result.connectionId);
|
||||
onConnectionChangeRef.current?.(true);
|
||||
|
||||
const removeDataListener = window.electronAPI.ssh.onData(result.connectionId, (data) => {
|
||||
xtermRef.current?.write(data);
|
||||
});
|
||||
|
||||
const removeCloseListener = window.electronAPI.ssh.onClose(result.connectionId, () => {
|
||||
if (isMountedRef.current) {
|
||||
onConnectionChangeRef.current?.(false);
|
||||
xtermRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m');
|
||||
connectionIdRef.current = null;
|
||||
setConnectionId(null);
|
||||
// 断开后允许重连
|
||||
hasConnectedRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
const removeErrorListener = window.electronAPI.ssh.onError(result.connectionId, (err) => {
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${err}\x1b[0m`);
|
||||
});
|
||||
|
||||
cleanupListenersRef.current = () => {
|
||||
removeDataListener();
|
||||
removeCloseListener();
|
||||
removeErrorListener();
|
||||
};
|
||||
|
||||
// 延迟一点再发送终端尺寸,确保 shell 准备好
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.resize(
|
||||
connectionIdRef.current,
|
||||
xtermRef.current.cols,
|
||||
xtermRef.current.rows
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('fit 失败:', e);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
hasConnectedRef.current = false;
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
hasConnectedRef.current = false;
|
||||
if (isMountedRef.current) {
|
||||
setError(err.message);
|
||||
onConnectionChangeRef.current?.(false);
|
||||
xtermRef.current?.writeln(`\x1b[31m连接失败: ${err.message}\x1b[0m`);
|
||||
}
|
||||
} finally {
|
||||
isConnectingRef.current = false;
|
||||
if (isMountedRef.current) {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [hostId]); // 只依赖 hostId
|
||||
|
||||
// 调整终端尺寸
|
||||
const fitTerminal = useCallback(() => {
|
||||
if (!xtermRef.current || !fitAddonRef.current || !terminalRef.current) return;
|
||||
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
|
||||
// 通知 SSH 服务器尺寸变化
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.resize(
|
||||
connectionIdRef.current,
|
||||
xtermRef.current.cols,
|
||||
xtermRef.current.rows
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('调整终端尺寸失败:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化终端
|
||||
const initTerminal = useCallback(() => {
|
||||
if (!terminalRef.current || xtermRef.current) return false;
|
||||
|
||||
const container = terminalRef.current;
|
||||
// 确保容器有有效尺寸
|
||||
if (container.clientWidth < 100 || container.clientHeight < 100) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
lineHeight: 1.4,
|
||||
scrollback: 1000,
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
selectionBackground: 'rgba(88, 166, 255, 0.3)',
|
||||
black: '#0d1117',
|
||||
red: '#f85149',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#56d4dd',
|
||||
white: '#e6edf3',
|
||||
brightBlack: '#484f58',
|
||||
brightRed: '#ff7b72',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#76e3ea',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
// 加载插件
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// 打开终端
|
||||
term.open(container);
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// 首次调整尺寸
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 0);
|
||||
|
||||
// 监听用户输入
|
||||
term.onData((data) => {
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.write(connectionIdRef.current, data);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听容器尺寸变化
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
fitTerminal();
|
||||
});
|
||||
resizeObserverRef.current.observe(container);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('终端初始化失败:', e);
|
||||
return false;
|
||||
}
|
||||
}, [fitTerminal]);
|
||||
|
||||
// 等待容器就绪后初始化
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const tryInit = () => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (initTerminal()) {
|
||||
setIsReady(true);
|
||||
// 初始化成功后连接
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
// 容器未就绪,继续尝试
|
||||
initTimerRef.current = setTimeout(tryInit, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟开始尝试初始化
|
||||
initTimerRef.current = setTimeout(tryInit, 200);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
if (initTimerRef.current) {
|
||||
clearTimeout(initTimerRef.current);
|
||||
}
|
||||
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
|
||||
if (cleanupListenersRef.current) {
|
||||
cleanupListenersRef.current();
|
||||
cleanupListenersRef.current = null;
|
||||
}
|
||||
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.disconnect(connectionIdRef.current);
|
||||
connectionIdRef.current = null;
|
||||
}
|
||||
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [initTerminal, connect]);
|
||||
|
||||
// 监听命令面板发送的命令
|
||||
useEffect(() => {
|
||||
const handleCommand = (e) => {
|
||||
if (e.detail.tabId === tabId && connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.write(connectionIdRef.current, e.detail.command + '\n');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('terminal-command', handleCommand);
|
||||
return () => window.removeEventListener('terminal-command', handleCommand);
|
||||
}, [tabId]);
|
||||
|
||||
// 重连
|
||||
const handleReconnect = useCallback(() => {
|
||||
if (cleanupListenersRef.current) {
|
||||
cleanupListenersRef.current();
|
||||
cleanupListenersRef.current = null;
|
||||
}
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.disconnect(connectionIdRef.current);
|
||||
}
|
||||
connectionIdRef.current = null;
|
||||
isConnectingRef.current = false;
|
||||
hasConnectedRef.current = false; // 重置连接标志
|
||||
setConnectionId(null);
|
||||
setError(null);
|
||||
|
||||
// 完全重置终端(清屏+重置光标位置)
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.reset();
|
||||
}
|
||||
|
||||
setTimeout(() => connect(), 100);
|
||||
}, [connect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full flex flex-col bg-shell-bg">
|
||||
{/* 终端工具栏 */}
|
||||
<div className="h-10 bg-shell-surface/50 border-b border-shell-border flex items-center px-4 justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{!isReady ? (
|
||||
<div className="flex items-center gap-2 text-shell-text-dim text-sm">
|
||||
<div className="w-3 h-3 border-2 border-shell-text-dim border-t-transparent rounded-full animate-spin" />
|
||||
初始化...
|
||||
</div>
|
||||
) : isConnecting ? (
|
||||
<div className="flex items-center gap-2 text-shell-warning text-sm">
|
||||
<div className="w-3 h-3 border-2 border-shell-warning border-t-transparent rounded-full animate-spin" />
|
||||
连接中...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2 text-shell-error text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-shell-error" />
|
||||
连接失败
|
||||
</div>
|
||||
) : connectionId ? (
|
||||
<div className="flex items-center gap-2 text-shell-success text-sm">
|
||||
<span className="w-2 h-2 rounded-full status-online" />
|
||||
已连接
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-shell-text-dim text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-shell-text-dim" />
|
||||
未连接
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onShowCommandPalette}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-shell-card
|
||||
border border-shell-border hover:border-shell-accent/50
|
||||
text-shell-text-dim hover:text-shell-text transition-all text-sm"
|
||||
title="命令面板 (Ctrl+K)"
|
||||
>
|
||||
<FiCommand size={14} />
|
||||
<span className="hidden sm:inline">命令提示</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={!isReady}
|
||||
className="p-2 rounded-md hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors disabled:opacity-50"
|
||||
title="重新连接"
|
||||
>
|
||||
<FiRefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终端内容 */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 p-2 terminal-container overflow-hidden"
|
||||
style={{ minHeight: '300px', minWidth: '400px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Terminal;
|
||||
78
src/components/TitleBar.js
Normal file
78
src/components/TitleBar.js
Normal file
@ -0,0 +1,78 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FiMinus, FiSquare, FiX, FiMaximize2 } from 'react-icons/fi';
|
||||
|
||||
function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMaximized = async () => {
|
||||
if (window.electronAPI) {
|
||||
const maximized = await window.electronAPI.window.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
}
|
||||
};
|
||||
checkMaximized();
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI?.window.minimize();
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
window.electronAPI?.window.maximize();
|
||||
const maximized = await window.electronAPI?.window.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electronAPI?.window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-9 bg-shell-surface/80 border-b border-shell-border flex items-center justify-between px-4 drag-region">
|
||||
{/* Logo 和标题 */}
|
||||
<div className="flex items-center gap-3 no-drag">
|
||||
<div className="w-5 h-5 rounded-md bg-gradient-to-br from-shell-accent to-shell-purple flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-white">E</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-shell-text">
|
||||
EasyShell
|
||||
</span>
|
||||
<span className="text-xs text-shell-text-dim px-2 py-0.5 bg-shell-card rounded-full">
|
||||
v1.0.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 窗口控制按钮 */}
|
||||
<div className="flex items-center gap-1 no-drag">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-shell-card
|
||||
text-shell-text-dim hover:text-shell-text transition-colors"
|
||||
title="最小化"
|
||||
>
|
||||
<FiMinus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-shell-card
|
||||
text-shell-text-dim hover:text-shell-text transition-colors"
|
||||
title={isMaximized ? '还原' : '最大化'}
|
||||
>
|
||||
{isMaximized ? <FiMaximize2 size={12} /> : <FiSquare size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-shell-error
|
||||
text-shell-text-dim hover:text-white transition-colors"
|
||||
title="关闭"
|
||||
>
|
||||
<FiX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TitleBar;
|
||||
|
||||
215
src/index.css
Normal file
215
src/index.css
Normal file
@ -0,0 +1,215 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e14 0%, #0d1117 50%, #161b22 100%);
|
||||
color: #e6edf3;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 终端字体 */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
/* 可拖拽区域 */
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(48, 54, 61, 0.6);
|
||||
}
|
||||
|
||||
.glass-hover:hover {
|
||||
background: rgba(30, 36, 44, 0.9);
|
||||
border-color: rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 发光边框 */
|
||||
.glow-border {
|
||||
box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.3),
|
||||
0 0 20px rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
.glow-border-active {
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.5),
|
||||
0 0 30px rgba(88, 166, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
input, textarea {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
.btn-glow {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(88, 166, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-glow:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.card-hover {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 终端样式 */
|
||||
.terminal-container {
|
||||
background: #0d1117;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(88, 166, 255, 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* 脉冲点 */
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* 状态颜色 */
|
||||
.status-online {
|
||||
background: #3fb950;
|
||||
box-shadow: 0 0 8px rgba(63, 185, 80, 0.6);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: #8b949e;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f85149;
|
||||
box-shadow: 0 0 8px rgba(248, 81, 73, 0.6);
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.gradient-bg {
|
||||
background: radial-gradient(ellipse at top left, rgba(88, 166, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(188, 140, 255, 0.08) 0%, transparent 50%),
|
||||
linear-gradient(135deg, #0a0e14 0%, #0d1117 100%);
|
||||
}
|
||||
|
||||
/* 标签页效果 */
|
||||
.tab-active {
|
||||
background: linear-gradient(180deg, rgba(88, 166, 255, 0.2) 0%, transparent 100%);
|
||||
border-bottom: 2px solid #58a6ff;
|
||||
}
|
||||
|
||||
/* 命令提示框 */
|
||||
.command-hint {
|
||||
background: rgba(22, 27, 34, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(48, 54, 61, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 代码高亮 */
|
||||
.code-highlight {
|
||||
color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
7
src/index.js
Normal file
7
src/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
509
src/services/database.js
Normal file
509
src/services/database.js
Normal file
@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 数据库服务 - 支持MySQL远程同步和SQLite本地存储
|
||||
* 使用 sql.js (SQLite WASM版本) 无需原生编译
|
||||
*/
|
||||
const initSqlJs = require('sql.js');
|
||||
const mysql = require('mysql2/promise');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { app } = require('electron');
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
this.mysqlConnection = null;
|
||||
this.sqliteDb = null;
|
||||
this.SQL = null;
|
||||
this.isRemoteConnected = false;
|
||||
this.dbPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SQLite数据库路径
|
||||
*/
|
||||
getSqlitePath() {
|
||||
const userDataPath = app?.getPath('userData') || process.cwd();
|
||||
return path.join(userDataPath, 'easyshell.db');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据库到文件
|
||||
*/
|
||||
saveDatabase() {
|
||||
if (this.sqliteDb && this.dbPath) {
|
||||
const data = this.sqliteDb.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(this.dbPath, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化本地SQLite数据库
|
||||
*/
|
||||
async initLocalDatabase() {
|
||||
try {
|
||||
// 初始化 sql.js
|
||||
this.SQL = await initSqlJs();
|
||||
this.dbPath = this.getSqlitePath();
|
||||
|
||||
// 尝试加载现有数据库
|
||||
if (fs.existsSync(this.dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(this.dbPath);
|
||||
this.sqliteDb = new this.SQL.Database(fileBuffer);
|
||||
} else {
|
||||
this.sqliteDb = new this.SQL.Database();
|
||||
}
|
||||
|
||||
this.createLocalTables();
|
||||
this.saveDatabase();
|
||||
console.log('✅ 本地数据库初始化成功');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('❌ 本地数据库初始化失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本地表结构
|
||||
*/
|
||||
createLocalTables() {
|
||||
// 主机信息表
|
||||
this.sqliteDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
private_key TEXT,
|
||||
group_name TEXT DEFAULT '默认分组',
|
||||
color TEXT DEFAULT '#58a6ff',
|
||||
description TEXT,
|
||||
last_connected_at TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
is_synced INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
// 命令提示/历史表
|
||||
this.sqliteDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
command TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
category TEXT DEFAULT '通用',
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
host_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (host_id) REFERENCES hosts(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// 命令片段/快捷命令表
|
||||
this.sqliteDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT DEFAULT '通用',
|
||||
hotkey TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 同步记录表
|
||||
this.sqliteDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_type TEXT NOT NULL,
|
||||
sync_time TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT,
|
||||
details TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// 插入默认命令提示
|
||||
const defaultCommands = [
|
||||
{ command: 'ls -la', description: '列出所有文件详细信息', category: '文件操作' },
|
||||
{ command: 'cd', description: '切换目录', category: '文件操作' },
|
||||
{ command: 'pwd', description: '显示当前目录', category: '文件操作' },
|
||||
{ command: 'mkdir', description: '创建目录', category: '文件操作' },
|
||||
{ command: 'rm -rf', description: '强制删除文件/目录', category: '文件操作' },
|
||||
{ command: 'cp -r', description: '递归复制文件/目录', category: '文件操作' },
|
||||
{ command: 'mv', description: '移动/重命名文件', category: '文件操作' },
|
||||
{ command: 'cat', description: '查看文件内容', category: '文件操作' },
|
||||
{ command: 'tail -f', description: '实时查看日志', category: '日志查看' },
|
||||
{ command: 'grep', description: '文本搜索', category: '文本处理' },
|
||||
{ command: 'ps aux', description: '查看所有进程', category: '系统管理' },
|
||||
{ command: 'top', description: '实时系统监控', category: '系统管理' },
|
||||
{ command: 'htop', description: '增强版系统监控', category: '系统管理' },
|
||||
{ command: 'df -h', description: '查看磁盘使用情况', category: '系统管理' },
|
||||
{ command: 'free -m', description: '查看内存使用情况', category: '系统管理' },
|
||||
{ command: 'netstat -tunlp', description: '查看网络连接', category: '网络' },
|
||||
{ command: 'systemctl status', description: '查看服务状态', category: '服务管理' },
|
||||
{ command: 'systemctl restart', description: '重启服务', category: '服务管理' },
|
||||
{ command: 'docker ps', description: '查看运行中的容器', category: 'Docker' },
|
||||
{ command: 'docker logs -f', description: '实时查看容器日志', category: 'Docker' },
|
||||
];
|
||||
|
||||
for (const cmd of defaultCommands) {
|
||||
try {
|
||||
this.sqliteDb.run(
|
||||
`INSERT OR IGNORE INTO commands (command, description, category) VALUES (?, ?, ?)`,
|
||||
[cmd.command, cmd.description, cmd.category]
|
||||
);
|
||||
} catch (e) {
|
||||
// 忽略重复插入错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接MySQL远程数据库
|
||||
*/
|
||||
async connectMySQL(config) {
|
||||
try {
|
||||
// 如果是 localhost,转换为 127.0.0.1 强制使用 IPv4
|
||||
let host = config.host;
|
||||
if (host === 'localhost') {
|
||||
host = '127.0.0.1';
|
||||
}
|
||||
|
||||
// 先不指定数据库连接,这样可以创建数据库
|
||||
this.mysqlConnection = await mysql.createConnection({
|
||||
host: host,
|
||||
port: config.port || 3306,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
// 自动建库建表
|
||||
await this.initRemoteDatabase(config.database || 'easyshell');
|
||||
|
||||
this.isRemoteConnected = true;
|
||||
console.log('✅ MySQL远程数据库连接成功');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('❌ MySQL连接失败:', error);
|
||||
this.isRemoteConnected = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化远程MySQL数据库(自动建库建表)
|
||||
*/
|
||||
async initRemoteDatabase(dbName) {
|
||||
try {
|
||||
// 创建数据库 - 使用 query 而不是 execute
|
||||
await this.mysqlConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
);
|
||||
|
||||
// 使用数据库 - 使用 query 而不是 execute
|
||||
await this.mysqlConnection.query(`USE \`${dbName}\``);
|
||||
|
||||
// 创建主机表
|
||||
await this.mysqlConnection.query(`
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INT DEFAULT 22,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password TEXT,
|
||||
private_key TEXT,
|
||||
group_name VARCHAR(100) DEFAULT '默认分组',
|
||||
color VARCHAR(20) DEFAULT '#58a6ff',
|
||||
description TEXT,
|
||||
last_connected_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
// 创建命令表
|
||||
await this.mysqlConnection.query(`
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
command TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100) DEFAULT '通用',
|
||||
usage_count INT DEFAULT 0,
|
||||
host_id INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
// 创建命令片段表
|
||||
await this.mysqlConnection.query(`
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100) DEFAULT '通用',
|
||||
hotkey VARCHAR(50),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
console.log('✅ 远程数据库表结构初始化完成');
|
||||
} catch (error) {
|
||||
console.error('❌ 远程数据库初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开MySQL连接
|
||||
*/
|
||||
async disconnectMySQL() {
|
||||
if (this.mysqlConnection) {
|
||||
await this.mysqlConnection.end();
|
||||
this.mysqlConnection = null;
|
||||
this.isRemoteConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步数据到远程
|
||||
*/
|
||||
async syncToRemote() {
|
||||
if (!this.isRemoteConnected) {
|
||||
return { success: false, error: '未连接到远程数据库' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取本地未同步的主机
|
||||
const localHosts = this.runQuery('SELECT * FROM hosts WHERE is_synced = 0');
|
||||
|
||||
for (const host of localHosts) {
|
||||
await this.mysqlConnection.execute(`
|
||||
INSERT INTO hosts (name, host, port, username, password, private_key, group_name, color, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
port = VALUES(port),
|
||||
username = VALUES(username),
|
||||
password = VALUES(password),
|
||||
private_key = VALUES(private_key),
|
||||
group_name = VALUES(group_name),
|
||||
color = VALUES(color),
|
||||
description = VALUES(description)
|
||||
`, [host.name, host.host, host.port, host.username, host.password, host.private_key, host.group_name, host.color, host.description]);
|
||||
|
||||
// 标记为已同步
|
||||
this.sqliteDb.run('UPDATE hosts SET is_synced = 1 WHERE id = ?', [host.id]);
|
||||
}
|
||||
|
||||
this.saveDatabase();
|
||||
|
||||
// 记录同步日志
|
||||
this.sqliteDb.run(
|
||||
`INSERT INTO sync_log (sync_type, status, details) VALUES ('upload', 'success', ?)`,
|
||||
[JSON.stringify({ synced_hosts: localHosts.length })]
|
||||
);
|
||||
this.saveDatabase();
|
||||
|
||||
return { success: true, synced: localHosts.length };
|
||||
} catch (error) {
|
||||
console.error('❌ 同步到远程失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程同步数据
|
||||
*/
|
||||
async syncFromRemote() {
|
||||
if (!this.isRemoteConnected) {
|
||||
return { success: false, error: '未连接到远程数据库' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取远程主机
|
||||
const [remoteHosts] = await this.mysqlConnection.execute('SELECT * FROM hosts');
|
||||
|
||||
for (const host of remoteHosts) {
|
||||
this.sqliteDb.run(`
|
||||
INSERT OR REPLACE INTO hosts (id, name, host, port, username, password, private_key, group_name, color, description, is_synced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`, [host.id, host.name, host.host, host.port, host.username, host.password, host.private_key, host.group_name, host.color, host.description]);
|
||||
}
|
||||
|
||||
// 同步命令
|
||||
const [remoteCommands] = await this.mysqlConnection.execute('SELECT * FROM commands');
|
||||
|
||||
for (const cmd of remoteCommands) {
|
||||
this.sqliteDb.run(`
|
||||
INSERT OR REPLACE INTO commands (id, command, description, category, usage_count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [cmd.id, cmd.command, cmd.description, cmd.category, cmd.usage_count]);
|
||||
}
|
||||
|
||||
this.saveDatabase();
|
||||
|
||||
// 记录同步日志
|
||||
this.sqliteDb.run(
|
||||
`INSERT INTO sync_log (sync_type, status, details) VALUES ('download', 'success', ?)`,
|
||||
[JSON.stringify({ hosts: remoteHosts.length, commands: remoteCommands.length })]
|
||||
);
|
||||
this.saveDatabase();
|
||||
|
||||
return { success: true, hosts: remoteHosts.length, commands: remoteCommands.length };
|
||||
} catch (error) {
|
||||
console.error('❌ 从远程同步失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询并返回结果数组
|
||||
*/
|
||||
runQuery(sql, params = []) {
|
||||
const stmt = this.sqliteDb.prepare(sql);
|
||||
stmt.bind(params);
|
||||
const results = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询并返回单个结果
|
||||
*/
|
||||
runQuerySingle(sql, params = []) {
|
||||
const results = this.runQuery(sql, params);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
// ========== 主机管理方法 ==========
|
||||
|
||||
getAllHosts() {
|
||||
return this.runQuery('SELECT * FROM hosts ORDER BY group_name, name');
|
||||
}
|
||||
|
||||
getHostById(id) {
|
||||
return this.runQuerySingle('SELECT * FROM hosts WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
addHost(host) {
|
||||
this.sqliteDb.run(`
|
||||
INSERT INTO hosts (name, host, port, username, password, private_key, group_name, color, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
host.name, host.host, host.port || 22, host.username,
|
||||
host.password, host.privateKey, host.groupName || '默认分组',
|
||||
host.color || '#58a6ff', host.description
|
||||
]);
|
||||
this.saveDatabase();
|
||||
|
||||
// 获取最后插入的ID
|
||||
const result = this.runQuerySingle('SELECT last_insert_rowid() as id');
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
updateHost(id, host) {
|
||||
this.sqliteDb.run(`
|
||||
UPDATE hosts SET
|
||||
name = ?, host = ?, port = ?, username = ?, password = ?,
|
||||
private_key = ?, group_name = ?, color = ?, description = ?,
|
||||
updated_at = CURRENT_TIMESTAMP, is_synced = 0
|
||||
WHERE id = ?
|
||||
`, [
|
||||
host.name, host.host, host.port, host.username, host.password,
|
||||
host.privateKey, host.groupName, host.color, host.description, id
|
||||
]);
|
||||
this.saveDatabase();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
deleteHost(id) {
|
||||
this.sqliteDb.run('DELETE FROM hosts WHERE id = ?', [id]);
|
||||
this.saveDatabase();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
updateLastConnected(id) {
|
||||
this.sqliteDb.run(`UPDATE hosts SET last_connected_at = CURRENT_TIMESTAMP WHERE id = ?`, [id]);
|
||||
this.saveDatabase();
|
||||
}
|
||||
|
||||
// ========== 命令提示方法 ==========
|
||||
|
||||
searchCommands(keyword) {
|
||||
return this.runQuery(`
|
||||
SELECT * FROM commands
|
||||
WHERE command LIKE ? OR description LIKE ?
|
||||
ORDER BY usage_count DESC, command
|
||||
LIMIT 20
|
||||
`, [`%${keyword}%`, `%${keyword}%`]);
|
||||
}
|
||||
|
||||
getAllCommands() {
|
||||
return this.runQuery('SELECT * FROM commands ORDER BY category, command');
|
||||
}
|
||||
|
||||
incrementCommandUsage(id) {
|
||||
this.sqliteDb.run('UPDATE commands SET usage_count = usage_count + 1 WHERE id = ?', [id]);
|
||||
this.saveDatabase();
|
||||
}
|
||||
|
||||
addCommand(command) {
|
||||
this.sqliteDb.run(`
|
||||
INSERT INTO commands (command, description, category)
|
||||
VALUES (?, ?, ?)
|
||||
`, [command.command, command.description, command.category || '通用']);
|
||||
this.saveDatabase();
|
||||
|
||||
const result = this.runQuerySingle('SELECT last_insert_rowid() as id');
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
// ========== 命令片段方法 ==========
|
||||
|
||||
getAllSnippets() {
|
||||
return this.runQuery('SELECT * FROM snippets ORDER BY category, name');
|
||||
}
|
||||
|
||||
addSnippet(snippet) {
|
||||
this.sqliteDb.run(`
|
||||
INSERT INTO snippets (name, command, description, category, hotkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [snippet.name, snippet.command, snippet.description, snippet.category, snippet.hotkey]);
|
||||
this.saveDatabase();
|
||||
|
||||
const result = this.runQuerySingle('SELECT last_insert_rowid() as id');
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
deleteSnippet(id) {
|
||||
this.sqliteDb.run('DELETE FROM snippets WHERE id = ?', [id]);
|
||||
this.saveDatabase();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ========== 关闭数据库 ==========
|
||||
|
||||
close() {
|
||||
if (this.sqliteDb) {
|
||||
this.saveDatabase();
|
||||
this.sqliteDb.close();
|
||||
}
|
||||
if (this.mysqlConnection) {
|
||||
this.mysqlConnection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DatabaseService();
|
||||
209
src/services/ssh.js
Normal file
209
src/services/ssh.js
Normal file
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* SSH连接服务
|
||||
*/
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
class SSHService {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建SSH连接
|
||||
*/
|
||||
connect(hostConfig, connectionId, callbacks = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
let resolved = false;
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`✅ SSH连接成功: ${hostConfig.host}`);
|
||||
this.connections.set(connectionId, conn);
|
||||
|
||||
// 创建shell
|
||||
conn.shell({ term: 'xterm-256color' }, (err, stream) => {
|
||||
if (err) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('data', (data) => {
|
||||
if (callbacks.onData) {
|
||||
callbacks.onData(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
console.log(`📤 SSH会话关闭: ${hostConfig.host}`);
|
||||
this.disconnect(connectionId);
|
||||
if (callbacks.onClose) {
|
||||
callbacks.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data) => {
|
||||
if (callbacks.onData) {
|
||||
callbacks.onData(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
connectionId,
|
||||
stream,
|
||||
write: (data) => stream.write(data),
|
||||
resize: (cols, rows) => stream.setWindow(rows, cols, 0, 0),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error(`❌ SSH连接错误: ${err.message}`);
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(err);
|
||||
}
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('close', () => {
|
||||
this.connections.delete(connectionId);
|
||||
if (callbacks.onClose) {
|
||||
callbacks.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
// 连接配置
|
||||
const connectConfig = {
|
||||
host: hostConfig.host,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 20000, // 增加超时时间
|
||||
keepaliveInterval: 10000,
|
||||
};
|
||||
|
||||
// 使用密码或私钥
|
||||
if (hostConfig.privateKey && hostConfig.privateKey.trim()) {
|
||||
connectConfig.privateKey = hostConfig.privateKey;
|
||||
}
|
||||
if (hostConfig.password && hostConfig.password.trim()) {
|
||||
connectConfig.password = hostConfig.password;
|
||||
}
|
||||
|
||||
// 如果没有提供认证方式
|
||||
if (!connectConfig.privateKey && !connectConfig.password) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error('请提供密码或SSH私钥'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connectConfig);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect(connectionId) {
|
||||
const conn = this.connections.get(connectionId);
|
||||
if (conn) {
|
||||
conn.end();
|
||||
this.connections.delete(connectionId);
|
||||
console.log(`📤 SSH连接已断开: ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开所有连接
|
||||
*/
|
||||
disconnectAll() {
|
||||
for (const [id, conn] of this.connections) {
|
||||
conn.end();
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个命令
|
||||
*/
|
||||
exec(hostConfig, command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('close', (code) => {
|
||||
conn.end();
|
||||
resolve({
|
||||
code,
|
||||
stdout: output,
|
||||
stderr: errorOutput,
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', reject);
|
||||
|
||||
const connectConfig = {
|
||||
host: hostConfig.host,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
};
|
||||
|
||||
if (hostConfig.privateKey) {
|
||||
connectConfig.privateKey = hostConfig.privateKey;
|
||||
} else if (hostConfig.password) {
|
||||
connectConfig.password = hostConfig.password;
|
||||
}
|
||||
|
||||
conn.connect(connectConfig);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async testConnection(hostConfig) {
|
||||
try {
|
||||
const result = await this.exec(hostConfig, 'echo "connected"');
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SSHService();
|
||||
|
||||
68
tailwind.config.js
Normal file
68
tailwind.config.js
Normal file
@ -0,0 +1,68 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
"./public/index.html"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'shell': {
|
||||
'bg': '#0a0e14',
|
||||
'surface': '#0d1117',
|
||||
'card': '#161b22',
|
||||
'border': '#30363d',
|
||||
'accent': '#58a6ff',
|
||||
'accent-glow': '#1f6feb',
|
||||
'success': '#3fb950',
|
||||
'warning': '#d29922',
|
||||
'error': '#f85149',
|
||||
'text': '#e6edf3',
|
||||
'text-dim': '#8b949e',
|
||||
'cyan': '#56d4dd',
|
||||
'purple': '#bc8cff',
|
||||
'orange': '#ffa657',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'monospace'],
|
||||
'display': ['Space Grotesk', 'SF Pro Display', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px rgba(88, 166, 255, 0.3)',
|
||||
'glow-lg': '0 0 40px rgba(88, 166, 255, 0.4)',
|
||||
'card': '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(88, 166, 255, 0.2)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(88, 166, 255, 0.6)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
'xs': '2px',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user