This commit is contained in:
Ethanfly 2025-12-26 19:57:52 +08:00
commit 95f842f6cb
20 changed files with 24604 additions and 0 deletions

33
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,184 @@
# 🚀 EasyShell
高颜值远程 Shell 管理终端 - 一款现代化的 SSH 连接管理工具
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue)
![Electron](https://img.shields.io/badge/Electron-28.0.0-47848F?logo=electron)
![React](https://img.shields.io/badge/React-18.2.0-61DAFB?logo=react)
![License](https://img.shields.io/badge/license-MIT-green)
## ✨ 特性
- 🎨 **高颜值界面** - 现代化深色主题,精心设计的 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 ToolsWindows/ XcodemacOS
### 安装步骤
```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
View 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

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View 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
View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

76
preload.js Normal file
View 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
View 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
View 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;

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

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

View 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
View 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
View 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
View 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
View 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
View 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: [],
}