Implement SFTP functionality and enhance UI/UX

- Added SFTP file management capabilities including list, upload, download, delete, and directory operations.
- Integrated SFTP progress callbacks to provide real-time feedback during file transfers.
- Updated the UI to include a dedicated SFTP browser and host information panel.
- Enhanced the sidebar and title bar with improved styling and animations for a cyberpunk theme.
- Refactored host management to support editing and connecting to hosts with a more intuitive interface.
- Updated package dependencies to support new features and improve performance.
This commit is contained in:
Ethanfly 2025-12-29 13:50:23 +08:00
parent b7f6e9fcf6
commit c0fe5b3321
30 changed files with 8112 additions and 670 deletions

320
README.md
View File

@ -1,178 +1,179 @@
# 🚀 EasyShell
# EasyShell 🚀
高颜值远程 Shell 管理终端 - 一款现代化的 SSH 连接管理工具
> 赛博朋克风格跨平台远程 Shell 管理终端
![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)
支持 **Windows / macOS / Linux / Android** 多平台运行。
## ✨ 特性
![EasyShell](https://img.shields.io/badge/version-1.0.0-blue) ![Electron](https://img.shields.io/badge/Electron-28-green) ![Capacitor](https://img.shields.io/badge/Capacitor-5.6-orange)
- 🎨 **高颜值界面** - 现代化深色主题,精心设计的 UI/UX
- 🔐 **SSH 连接管理** - 支持密码和私钥认证方式
- 💾 **双模式存储** - 本地 SQLite + 远程 MySQL 同步
- 📝 **智能命令提示** - 内置常用命令,支持搜索和使用频率排序
- 🔄 **数据同步** - 自动建库建表,一键上传/下载数据
- 📑 **多标签终端** - 同时管理多个 SSH 会话
- ⌨️ **快捷键支持** - Ctrl+K 打开命令面板
## ✨ 功能特点
## 📸 界面预览
- 🎨 **赛博朋克 UI** - 霓虹色调、玻璃拟态、动态特效
- 🖥️ **SSH 终端** - 完整的 xterm.js 终端模拟
- 📁 **SFTP 文件管理** - 远程文件浏览、上传、下载
- 📊 **主机信息面板** - 实时系统状态监控
- ☁️ **云端同步** - MySQL 数据库同步支持
- 📱 **跨平台** - 桌面端和移动端统一体验
应用采用深色主题设计,包含:
- 可折叠侧边栏(主机列表分组显示)
- 多标签终端区域
- 命令面板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 服务
├── src/ # React 前端源码
│ ├── components/ # UI 组件
│ ├── services/ # 服务层
│ │ ├── api.js # 跨平台 API 适配层
│ │ ├── database.js # 数据库服务
│ │ ├── ssh.js # SSH 服务 (Electron)
│ │ └── sftp.js # SFTP 服务 (Electron)
│ └── ...
├── server/ # 后端服务器 (移动端需要)
│ ├── index.js # Express + Socket.IO 服务
│ └── package.json
├── android/ # Android 原生项目 (Capacitor)
├── main.js # Electron 主进程
├── preload.js # Electron 预加载脚本
└── capacitor.config.ts # Capacitor 配置
```
## ⌨️ 快捷键
## 🚀 快速开始
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+K` | 打开命令面板 |
| `Esc` | 关闭弹窗 |
| `↑/↓` | 命令面板中导航 |
| `Enter` | 执行选中命令 |
### 安装依赖
## 🔧 数据库结构
```bash
# 安装前端依赖
npm install
### 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 | 备注 |
# 安装服务器依赖
cd server && npm install && cd ..
```
### commands 表(命令提示)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT | 主键 |
| command | TEXT | 命令内容 |
| description | TEXT | 命令描述 |
| category | VARCHAR | 分类 |
| usage_count | INT | 使用次数 |
### 桌面端开发
## 🤝 贡献
```bash
# 启动 Electron 开发模式
npm start
```
欢迎提交 Issue 和 Pull Request
### 移动端开发
#### 1. 启动后端服务器
```bash
# 在电脑上启动服务器
npm run server
# 或开发模式 (自动重启)
npm run server:dev
```
服务器将在 `http://0.0.0.0:3001` 启动。
#### 2. 构建并运行安卓应用
```bash
# 首次使用需要初始化
npm run cap:add:android
# 构建并打开 Android Studio
npm run android
# 或直接运行到设备
npm run android:run
```
#### 3. 在手机上配置
1. 确保手机和电脑在同一局域网
2. 打开 EasyShell 应用
3. 点击右上角设置图标
4. 输入电脑 IP 地址,如 `http://192.168.1.100:3001`
5. 点击测试连接,确认成功后保存
## 📦 构建发布
### 桌面端
```bash
# Windows
npm run dist
# macOS (需要在 Mac 上构建)
npm run dist
# Linux
npm run dist
```
### 安卓端
```bash
# 构建 Release APK
npm run build
npx cap sync android
cd android && ./gradlew assembleRelease
```
APK 位于 `android/app/build/outputs/apk/release/`
## 🔧 配置说明
### 服务器配置
服务器默认端口 `3001`,可通过环境变量修改:
```bash
PORT=8080 npm run server
```
### Capacitor 配置
编辑 `capacitor.config.ts` 可以修改:
- 应用 ID
- 状态栏样式
- 背景颜色
- 等等
## 🛡️ 安全说明
- SSH 密码和私钥存储在本地 (桌面端使用 electron-store移动端使用 localStorage)
- 移动端通过 WebSocket 与后端服务器通信
- 建议在受信任的网络环境中使用
- 生产环境建议配置 HTTPS/WSS
## 📱 移动端限制
由于浏览器安全限制,移动端有以下差异:
| 功能 | 桌面端 | 移动端 |
|------|--------|--------|
| SSH 连接 | 直连 | 通过服务器代理 |
| SFTP 上传 | ✅ | ⚠️ 需要文件选择器 |
| MySQL 同步 | ✅ | ❌ 暂不支持 |
| 窗口控制 | ✅ | ❌ 不需要 |
## 🤝 技术栈
**前端:**
- React 18
- Tailwind CSS
- Framer Motion
- xterm.js
**桌面端:**
- Electron 28
- electron-store
- ssh2
**移动端:**
- Capacitor 5
- Socket.IO
**后端:**
- Express
- Socket.IO
- ssh2
## 📄 许可证
@ -180,5 +181,4 @@ MIT License
---
Made with ❤️ by EasyShell Team
Made with ❤️ and ⚡ by EasyShell Team

39
capacitor.config.ts Normal file
View File

@ -0,0 +1,39 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.easyshell.app',
appName: 'EasyShell',
webDir: 'build',
server: {
// 开发时可以指向本地服务器
// url: 'http://192.168.1.100:3000',
// cleartext: true,
androidScheme: 'https',
},
plugins: {
StatusBar: {
style: 'DARK',
backgroundColor: '#050810',
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
App: {
// 防止返回键直接退出
},
},
android: {
allowMixedContent: true,
backgroundColor: '#050810',
// 全屏沉浸模式
// useLegacyBridge: true,
},
ios: {
backgroundColor: '#050810',
contentInset: 'automatic',
},
};
export default config;

62
main.js
View File

@ -6,9 +6,10 @@ const path = require('path');
const Store = require('electron-store');
const databaseService = require('./src/services/database');
const sshService = require('./src/services/ssh');
const sftpService = require('./src/services/sftp');
let mainWindow;
const isDev = process.env.NODE_ENV !== 'production' || !app.isPackaged;
const isDev = !app.isPackaged; // 只根据是否打包来判断开发模式
// 配置存储
const configStore = new Store({
@ -182,8 +183,8 @@ ipcMain.handle('hosts:update', (event, { id, host }) => {
return databaseService.updateHost(id, host);
});
ipcMain.handle('hosts:delete', (event, id) => {
return databaseService.deleteHost(id);
ipcMain.handle('hosts:delete', async (event, id) => {
return await databaseService.deleteHost(id);
});
// 命令
@ -276,3 +277,58 @@ ipcMain.handle('ssh:exec', async (event, { hostConfig, command }) => {
return await sshService.exec(hostConfig, command);
});
// ========== SFTP IPC ==========
// 设置进度回调
sftpService.setProgressCallback((progress) => {
mainWindow?.webContents.send('sftp:progress', progress);
});
ipcMain.handle('sftp:list', async (event, { hostConfig, remotePath }) => {
return await sftpService.list(hostConfig, remotePath);
});
ipcMain.handle('sftp:download', async (event, { hostConfig, remotePath }) => {
return await sftpService.download(hostConfig, remotePath, mainWindow);
});
ipcMain.handle('sftp:upload', async (event, { hostConfig, localPath, remotePath }) => {
return await sftpService.upload(hostConfig, localPath, remotePath);
});
ipcMain.handle('sftp:delete', async (event, { hostConfig, remotePath }) => {
return await sftpService.delete(hostConfig, remotePath);
});
ipcMain.handle('sftp:mkdir', async (event, { hostConfig, remotePath }) => {
return await sftpService.mkdir(hostConfig, remotePath);
});
ipcMain.handle('sftp:rmdir', async (event, { hostConfig, remotePath }) => {
return await sftpService.rmdir(hostConfig, remotePath);
});
ipcMain.handle('sftp:rename', async (event, { hostConfig, oldPath, newPath }) => {
return await sftpService.rename(hostConfig, oldPath, newPath);
});
ipcMain.handle('sftp:writeFile', async (event, { hostConfig, remotePath, content }) => {
return await sftpService.writeFile(hostConfig, remotePath, content);
});
ipcMain.handle('sftp:readFile', async (event, { hostConfig, remotePath }) => {
return await sftpService.readFile(hostConfig, remotePath);
});
ipcMain.handle('sftp:stat', async (event, { hostConfig, remotePath }) => {
return await sftpService.stat(hostConfig, remotePath);
});
ipcMain.handle('sftp:chmod', async (event, { hostConfig, remotePath, mode }) => {
return await sftpService.chmod(hostConfig, remotePath, mode);
});
ipcMain.handle('sftp:chown', async (event, { hostConfig, remotePath, uid, gid }) => {
return await sftpService.chown(hostConfig, remotePath, uid, gid);
});

2183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,28 @@
{
"name": "easyshell",
"version": "1.0.0",
"description": "高颜值远程Shell管理终端",
"description": "跨平台远程Shell管理终端 - 支持 Windows/Mac/Linux/Android",
"author": "EasyShell Team",
"main": "main.js",
"homepage": "./",
"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"
"dist": "npm run icons && npm run build && electron-builder",
"icons": "node scripts/generate-icons.js",
"server": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"android": "npm run build && npx cap sync android && npx cap open android",
"android:run": "npm run build && npx cap sync android && npx cap run android",
"ios": "npm run build && npx cap sync ios && npx cap open ios",
"cap:init": "npx cap init EasyShell com.easyshell.app --web-dir=build",
"cap:add:android": "npx cap add android",
"cap:add:ios": "npx cap add ios",
"cap:sync": "npx cap sync",
"full:android": "npm run build && npm run cap:sync && npx cap open android"
},
"build": {
"appId": "com.easyshell.app",
@ -21,14 +34,49 @@
"build/**/*",
"main.js",
"preload.js",
"src/services/**/*"
"src/services/database.js",
"src/services/ssh.js",
"src/services/sftp.js",
"node_modules/**/*",
"!node_modules/.cache/**/*"
],
"extraMetadata": {
"main": "main.js"
},
"asar": true,
"asarUnpack": [
"node_modules/ssh2/**/*",
"node_modules/cpu-features/**/*"
],
"win": {
"target": "nsis",
"icon": "public/icon.ico"
},
"mac": {
"target": "dmg",
"icon": "public/icon.icns"
},
"linux": {
"target": "AppImage",
"icon": "public/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
},
"dependencies": {
"@capacitor/android": "^5.6.0",
"@capacitor/app": "^5.0.6",
"@capacitor/core": "^5.6.0",
"@capacitor/haptics": "^5.0.6",
"@capacitor/keyboard": "^5.0.6",
"@capacitor/status-bar": "^5.0.6",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"electron-store": "^8.1.0",
"framer-motion": "^10.16.16",
"mysql2": "^3.6.5",
@ -36,28 +84,34 @@
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.6.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"
"ssh2": "^1.15.0"
},
"devDependencies": {
"@capacitor/cli": "^5.6.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"png-to-ico": "^3.0.1",
"postcss": "^8.4.32",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.0",
"to-ico": "^1.1.5",
"wait-on": "^7.2.0"
},
"browserslist": {
"production": [
"last 1 electron version"
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 electron version"
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
}

View File

@ -75,5 +75,28 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeAllListeners(channel);
},
},
// SFTP 文件操作
sftp: {
list: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:list', { hostConfig, remotePath }),
download: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:download', { hostConfig, remotePath }),
upload: (hostConfig, localPath, remotePath) => ipcRenderer.invoke('sftp:upload', { hostConfig, localPath, remotePath }),
delete: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:delete', { hostConfig, remotePath }),
mkdir: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:mkdir', { hostConfig, remotePath }),
rmdir: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:rmdir', { hostConfig, remotePath }),
rename: (hostConfig, oldPath, newPath) => ipcRenderer.invoke('sftp:rename', { hostConfig, oldPath, newPath }),
writeFile: (hostConfig, remotePath, content) => ipcRenderer.invoke('sftp:writeFile', { hostConfig, remotePath, content }),
readFile: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:readFile', { hostConfig, remotePath }),
stat: (hostConfig, remotePath) => ipcRenderer.invoke('sftp:stat', { hostConfig, remotePath }),
chmod: (hostConfig, remotePath, mode) => ipcRenderer.invoke('sftp:chmod', { hostConfig, remotePath, mode }),
chown: (hostConfig, remotePath, uid, gid) => ipcRenderer.invoke('sftp:chown', { hostConfig, remotePath, uid, gid }),
// 传输进度事件
onProgress: (callback) => {
const channel = 'sftp:progress';
ipcRenderer.on(channel, (event, progress) => callback(progress));
return () => ipcRenderer.removeAllListeners(channel);
},
},
});

BIN
public/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

47
public/icon.svg Normal file
View File

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00f5ff"/>
<stop offset="100%" style="stop-color:#a855f7"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="256" height="256" rx="48" fill="#0a0a15"/>
<!-- 边框 -->
<rect x="4" y="4" width="248" height="248" rx="44" fill="none" stroke="url(#grad)" stroke-width="4"/>
<!-- 终端窗口 -->
<rect x="32" y="48" width="192" height="160" rx="12" fill="#0d1117" stroke="#00f5ff" stroke-width="2"/>
<!-- 标题栏 -->
<rect x="32" y="48" width="192" height="28" rx="12" fill="#161b22"/>
<rect x="32" y="64" width="192" height="12" fill="#161b22"/>
<!-- 窗口按钮 -->
<circle cx="52" cy="62" r="5" fill="#ff5f56"/>
<circle cx="68" cy="62" r="5" fill="#ffbd2e"/>
<circle cx="84" cy="62" r="5" fill="#27ca40"/>
<!-- Shell > 符号 -->
<g filter="url(#glow)">
<path d="M60 115 L95 145 L60 175"
fill="none"
stroke="#00f5ff"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"/>
</g>
<!-- 光标 -->
<rect x="115" y="132" width="80" height="26" rx="4" fill="url(#grad)" filter="url(#glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,43 +1,147 @@
<!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>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#050810" />
<meta name="description" content="EasyShell - 赛博朋克风格远程Shell管理终端" />
<title>EasyShell</title>
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/icon.svg" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.svg" />
<!-- Google Fonts - 科技感字体 -->
<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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #050810;
}
/* 全局滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(10, 15, 24, 0.5);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #1a2332 0%, #253244 100%);
border-radius: 4px;
border: 1px solid rgba(0, 212, 255, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #253244 0%, rgba(0, 212, 255, 0.2) 100%);
}
/* 加载动画 */
#loading-screen {
position: fixed;
inset: 0;
background: #050810;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.5s ease;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-ring {
width: 60px;
height: 60px;
border: 3px solid rgba(26, 35, 50, 0.5);
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loader-text {
margin-top: 20px;
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
color: #6b7a94;
letter-spacing: 3px;
text-transform: uppercase;
}
.loader-text::after {
content: '';
animation: dots 1.5s steps(4) infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes dots {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
100% {
content: '';
}
}
</style>
</head>
<body>
<!-- 加载屏幕 -->
<div id="loading-screen">
<img src="icon.svg" alt="EasyShell"
style="width: 80px; height: 80px; margin-bottom: 20px; filter: drop-shadow(0 0 20px rgba(0, 245, 255, 0.5));" />
<div class="loader-ring"></div>
<div class="loader-text">INITIALIZING</div>
</div>
<noscript>您需要启用 JavaScript 才能运行此应用。</noscript>
<div id="root"></div>
<script>
// 应用加载完成后隐藏加载屏幕
window.addEventListener('load', function () {
setTimeout(function () {
var loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(function () {
loadingScreen.style.display = 'none';
}, 500);
}
}, 500);
});
</script>
</body>
</html>

108
public/logo.svg Normal file
View File

@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<!-- 主渐变 - 青色到紫色 -->
<linearGradient id="mainGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00f5ff;stop-opacity:1" />
<stop offset="50%" style="stop-color:#a855f7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ff6b35;stop-opacity:1" />
</linearGradient>
<!-- 发光效果 -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 外发光 -->
<filter id="outerGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="12" result="blur"/>
<feFlood flood-color="#00f5ff" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 内部渐变 -->
<linearGradient id="shellGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00f5ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:1" />
</linearGradient>
<!-- 背景渐变 -->
<radialGradient id="bgGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a0a15;stop-opacity:1" />
</radialGradient>
</defs>
<!-- 背景圆形 -->
<circle cx="256" cy="256" r="250" fill="url(#bgGradient)" stroke="url(#mainGradient)" stroke-width="4"/>
<!-- 六边形装饰框 -->
<polygon
points="256,40 430,130 430,310 256,400 82,310 82,130"
fill="none"
stroke="url(#mainGradient)"
stroke-width="3"
opacity="0.5"
/>
<!-- 内部六边形 -->
<polygon
points="256,80 390,150 390,290 256,360 122,290 122,150"
fill="none"
stroke="#00f5ff"
stroke-width="2"
opacity="0.3"
/>
<!-- 终端窗口背景 -->
<rect x="120" y="140" width="272" height="200" rx="12" ry="12"
fill="#0d1117" stroke="url(#shellGradient)" stroke-width="3" filter="url(#outerGlow)"/>
<!-- 终端标题栏 -->
<rect x="120" y="140" width="272" height="32" rx="12" ry="12" fill="#161b22"/>
<rect x="120" y="160" width="272" height="12" fill="#161b22"/>
<!-- 窗口按钮 -->
<circle cx="142" cy="156" r="6" fill="#ff5f56"/>
<circle cx="162" cy="156" r="6" fill="#ffbd2e"/>
<circle cx="182" cy="156" r="6" fill="#27ca40"/>
<!-- Shell 提示符 > -->
<g filter="url(#glow)">
<path d="M160 220 L200 250 L160 280"
fill="none"
stroke="#00f5ff"
stroke-width="12"
stroke-linecap="round"
stroke-linejoin="round"/>
</g>
<!-- 光标闪烁线 -->
<rect x="220" y="235" width="120" height="30" rx="4" fill="url(#shellGradient)" opacity="0.9" filter="url(#glow)"/>
<!-- 装饰性扫描线 -->
<line x1="140" y1="300" x2="372" y2="300" stroke="#00f5ff" stroke-width="1" opacity="0.5"/>
<line x1="140" y1="310" x2="320" y2="310" stroke="#a855f7" stroke-width="1" opacity="0.3"/>
<!-- 底部装饰点 -->
<circle cx="256" cy="420" r="8" fill="#00f5ff" filter="url(#glow)"/>
<circle cx="220" cy="430" r="4" fill="#a855f7" opacity="0.7"/>
<circle cx="292" cy="430" r="4" fill="#ff6b35" opacity="0.7"/>
<!-- 顶部装饰 -->
<polygon points="256,60 266,80 246,80" fill="#00f5ff" filter="url(#glow)"/>
<!-- 角落装饰 -->
<path d="M80 180 L80 140 L120 140" fill="none" stroke="#00f5ff" stroke-width="2" opacity="0.6"/>
<path d="M432 180 L432 140 L392 140" fill="none" stroke="#a855f7" stroke-width="2" opacity="0.6"/>
<path d="M80 332 L80 372 L120 372" fill="none" stroke="#ff6b35" stroke-width="2" opacity="0.6"/>
<path d="M432 332 L432 372 L392 372" fill="none" stroke="#00f5ff" stroke-width="2" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

80
scripts/generate-icons.js Normal file
View File

@ -0,0 +1,80 @@
/**
* 图标生成脚本
* SVG 转换为各平台所需的图标格式
*/
const sharp = require('sharp');
const toIco = require('to-ico');
const fs = require('fs');
const path = require('path');
const publicDir = path.join(__dirname, '../public');
const svgPath = path.join(publicDir, 'icon.svg');
// 需要生成的 PNG 尺寸
const sizes = [16, 24, 32, 48, 64, 128, 256, 512];
async function generateIcons() {
console.log('🎨 开始生成图标...\n');
// 读取 SVG 文件
const svgBuffer = fs.readFileSync(svgPath);
// 生成各尺寸 PNG
const pngBuffers = {};
for (const size of sizes) {
const outputPath = path.join(publicDir, `icon-${size}.png`);
const pngBuffer = await sharp(svgBuffer)
.resize(size, size)
.png()
.toBuffer();
fs.writeFileSync(outputPath, pngBuffer);
pngBuffers[size] = pngBuffer;
console.log(`✅ 生成 icon-${size}.png`);
}
// 生成主 PNG 图标 (256x256用于任务栏)
const mainPngPath = path.join(publicDir, 'icon.png');
fs.writeFileSync(mainPngPath, pngBuffers[256]);
console.log('✅ 生成 icon.png (256x256)');
// 生成 Windows ICO 文件 (包含多尺寸)
try {
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
const icoPngBuffers = icoSizes.map(s => pngBuffers[s]);
const icoBuffer = await toIco(icoPngBuffers);
fs.writeFileSync(path.join(publicDir, 'icon.ico'), icoBuffer);
console.log('✅ 生成 icon.ico (Windows 图标)');
} catch (err) {
console.error('❌ 生成 ICO 失败:', err.message);
}
// 生成 macOS ICNS 说明
console.log('\n📝 macOS 图标说明:');
console.log(' 使用 icon-512.png 通过 iconutil 生成 .icns 文件');
console.log(' 或使用在线工具: https://cloudconvert.com/png-to-icns\n');
// 清理临时文件(保留常用尺寸)
const keepSizes = [256, 512];
for (const size of sizes) {
if (!keepSizes.includes(size)) {
const tempPath = path.join(publicDir, `icon-${size}.png`);
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
console.log('🎉 图标生成完成!\n');
console.log('生成的文件:');
console.log(' - public/icon.png (任务栏/窗口图标)');
console.log(' - public/icon.ico (Windows 安装包/桌面图标)');
console.log(' - public/icon.svg (Web/高清图标)');
console.log(' - public/icon-256.png (备用)');
console.log(' - public/icon-512.png (macOS 用)');
}
generateIcons().catch(console.error);

471
server/index.js Normal file
View File

@ -0,0 +1,471 @@
/**
* EasyShell - 后端服务器
* 提供 SSH 代理SFTP 服务和 WebSocket 通信
*/
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const { Client } = require('ssh2');
const path = require('path');
const fs = require('fs');
const app = express();
const server = http.createServer(app);
// Socket.IO 配置
const io = new Server(server, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
pingTimeout: 60000,
pingInterval: 25000,
});
// 中间件
app.use(cors());
app.use(express.json());
// 存储活动的 SSH 连接
const sshConnections = new Map();
const sftpConnections = new Map();
// 健康检查
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// 获取服务器信息
app.get('/info', (req, res) => {
res.json({
name: 'EasyShell Server',
version: '1.0.0',
connections: sshConnections.size,
});
});
// ========== Socket.IO 事件处理 ==========
io.on('connection', (socket) => {
console.log(`✅ 客户端连接: ${socket.id}`);
// SSH 连接
socket.on('ssh:connect', async (hostConfig, callback) => {
const connectionId = `${socket.id}-${Date.now()}`;
try {
const conn = new Client();
conn.on('ready', () => {
console.log(`✅ SSH 连接成功: ${hostConfig.host}`);
// 创建 shell
conn.shell({ term: 'xterm-256color' }, (err, stream) => {
if (err) {
callback({ success: false, error: err.message });
return;
}
// 存储连接
sshConnections.set(connectionId, { conn, stream, hostConfig });
// 数据传输
stream.on('data', (data) => {
socket.emit(`ssh:data:${connectionId}`, data.toString());
});
stream.stderr.on('data', (data) => {
socket.emit(`ssh:data:${connectionId}`, data.toString());
});
stream.on('close', () => {
console.log(`📤 SSH 会话关闭: ${hostConfig.host}`);
socket.emit(`ssh:close:${connectionId}`);
sshConnections.delete(connectionId);
});
callback({ success: true, connectionId });
});
});
conn.on('error', (err) => {
console.error(`❌ SSH 连接错误: ${err.message}`);
socket.emit(`ssh:error:${connectionId}`, err.message);
callback({ success: false, error: err.message });
});
// 连接配置
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;
}
conn.connect(connectConfig);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SSH 写入数据
socket.on('ssh:write', ({ connectionId, data }) => {
const connection = sshConnections.get(connectionId);
if (connection?.stream) {
connection.stream.write(data);
}
});
// SSH 调整窗口大小
socket.on('ssh:resize', ({ connectionId, cols, rows }) => {
const connection = sshConnections.get(connectionId);
if (connection?.stream) {
connection.stream.setWindow(rows, cols, 0, 0);
}
});
// SSH 断开连接
socket.on('ssh:disconnect', (connectionId) => {
const connection = sshConnections.get(connectionId);
if (connection) {
connection.conn.end();
sshConnections.delete(connectionId);
console.log(`📤 SSH 连接已断开: ${connectionId}`);
}
});
// SSH 执行命令
socket.on('ssh:exec', async ({ hostConfig, command }, callback) => {
const conn = new Client();
let output = '';
let errorOutput = '';
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
callback({ success: false, error: err.message });
return;
}
stream.on('close', (code) => {
conn.end();
callback({
success: true,
code,
stdout: output,
stderr: errorOutput,
});
});
stream.on('data', (data) => {
output += data.toString();
});
stream.stderr.on('data', (data) => {
errorOutput += data.toString();
});
});
});
conn.on('error', (err) => {
callback({ success: false, error: err.message });
});
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);
});
// ========== SFTP 操作 ==========
// SFTP 列出目录
socket.on('sftp:list', async ({ hostConfig, remotePath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) {
reject(err);
return;
}
const files = list.map(item => ({
filename: item.filename,
longname: item.longname,
attrs: {
size: item.attrs.size,
mtime: item.attrs.mtime,
atime: item.attrs.atime,
mode: item.attrs.mode,
isDirectory: (item.attrs.mode & 0o40000) === 0o40000,
isFile: (item.attrs.mode & 0o100000) === 0o100000,
}
}));
resolve({ success: true, files });
});
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 创建目录
socket.on('sftp:mkdir', async ({ hostConfig, remotePath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) reject(err);
else resolve({ success: true });
});
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 删除文件
socket.on('sftp:delete', async ({ hostConfig, remotePath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) reject(err);
else resolve({ success: true });
});
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 删除目录(递归)
socket.on('sftp:rmdir', async ({ hostConfig, remotePath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
const deleteRecursive = async (dirPath) => {
return new Promise((resolve, reject) => {
sftp.readdir(dirPath, async (err, list) => {
if (err) {
reject(err);
return;
}
try {
for (const item of list) {
const itemPath = `${dirPath}/${item.filename}`;
const isDir = (item.attrs.mode & 0o40000) === 0o40000;
if (isDir) {
await deleteRecursive(itemPath);
} else {
await new Promise((res, rej) => {
sftp.unlink(itemPath, (err) => {
if (err) rej(err);
else res();
});
});
}
}
sftp.rmdir(dirPath, (err) => {
if (err) reject(err);
else resolve();
});
} catch (e) {
reject(e);
}
});
});
};
await deleteRecursive(remotePath);
return { success: true };
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 重命名
socket.on('sftp:rename', async ({ hostConfig, oldPath, newPath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
sftp.rename(oldPath, newPath, (err) => {
if (err) reject(err);
else resolve({ success: true });
});
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 读取文件
socket.on('sftp:readFile', async ({ hostConfig, remotePath }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
let content = '';
const readStream = sftp.createReadStream(remotePath);
readStream.on('data', (chunk) => {
content += chunk.toString();
});
readStream.on('error', reject);
readStream.on('end', () => {
resolve({ success: true, content });
});
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// SFTP 写入文件
socket.on('sftp:writeFile', async ({ hostConfig, remotePath, content }, callback) => {
try {
const result = await sftpOperation(hostConfig, async (sftp) => {
return new Promise((resolve, reject) => {
const writeStream = sftp.createWriteStream(remotePath);
writeStream.on('error', reject);
writeStream.on('close', () => resolve({ success: true }));
writeStream.end(content);
});
});
callback(result);
} catch (error) {
callback({ success: false, error: error.message });
}
});
// 客户端断开连接时清理
socket.on('disconnect', () => {
console.log(`📤 客户端断开: ${socket.id}`);
// 清理该客户端的所有 SSH 连接
for (const [id, connection] of sshConnections.entries()) {
if (id.startsWith(socket.id)) {
connection.conn.end();
sshConnections.delete(id);
}
}
});
});
// SFTP 操作辅助函数
async function sftpOperation(hostConfig, operation) {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.sftp(async (err, sftp) => {
if (err) {
conn.end();
reject(err);
return;
}
try {
const result = await operation(sftp);
conn.end();
resolve(result);
} catch (error) {
conn.end();
reject(error);
}
});
});
conn.on('error', reject);
const connectConfig = {
host: hostConfig.host,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 20000,
};
if (hostConfig.privateKey && hostConfig.privateKey.trim()) {
connectConfig.privateKey = hostConfig.privateKey;
}
if (hostConfig.password && hostConfig.password.trim()) {
connectConfig.password = hostConfig.password;
}
conn.connect(connectConfig);
});
}
// 启动服务器
const PORT = process.env.PORT || 3001;
server.listen(PORT, '0.0.0.0', () => {
console.log(`
EasyShell Server v1.0.0
🌐 HTTP: http://0.0.0.0:${PORT} ║
🔌 Socket: ws://0.0.0.0:${PORT} ║
Ready for connections...
`);
});
// 优雅关闭
process.on('SIGTERM', () => {
console.log('正在关闭服务器...');
// 关闭所有 SSH 连接
for (const [id, connection] of sshConnections.entries()) {
connection.conn.end();
}
server.close(() => {
console.log('服务器已关闭');
process.exit(0);
});
});
module.exports = { app, server, io };

20
server/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "easyshell-server",
"version": "1.0.0",
"description": "EasyShell 后端服务器 - SSH/SFTP 代理",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"socket.io": "^4.6.1",
"ssh2": "^1.15.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@ -6,6 +6,11 @@ import Terminal from './components/Terminal';
import HostManager from './components/HostManager';
import Settings from './components/Settings';
import CommandPalette from './components/CommandPalette';
import HostInfoPanel from './components/HostInfoPanel';
import SFTPBrowser from './components/SFTPBrowser';
import ServerConfig from './components/ServerConfig';
import HostEditPanel from './components/HostEditPanel';
import { getAPI, platform } from './services/api';
function App() {
const [hosts, setHosts] = useState([]);
@ -17,26 +22,32 @@ function App() {
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [showServerConfig, setShowServerConfig] = useState(false);
const [selectedHost, setSelectedHost] = useState(null); // 选中的主机(用于右侧编辑面板)
// 获取跨平台 API
const api = useMemo(() => getAPI(), []);
// 检测是否是移动端
const isMobile = platform.isMobile();
// 加载主机列表
const loadHosts = useCallback(async () => {
if (window.electronAPI) {
const hostList = await window.electronAPI.hosts.getAll();
setHosts(hostList);
}
}, []);
const hostList = await api.hosts.getAll();
setHosts(hostList || []);
}, [api]);
// 检查远程连接状态
const checkRemoteStatus = useCallback(async () => {
if (window.electronAPI) {
const connected = await window.electronAPI.db.isRemoteConnected();
setIsRemoteConnected(connected);
// 如果已连接,刷新主机列表(因为启动时可能已自动同步)
if (connected) {
loadHosts();
}
const connected = await api.db.isRemoteConnected();
setIsRemoteConnected(connected);
// 如果已连接,刷新主机列表(因为启动时可能已自动同步)
if (connected) {
loadHosts();
}
}, [loadHosts]);
}, [api, loadHosts]);
useEffect(() => {
loadHosts();
@ -105,12 +116,22 @@ function App() {
loadHosts();
}, [loadHosts]);
// 编辑主机
// 编辑主机 - 打开模态框
const handleEditHost = useCallback((host) => {
setEditingHost(host);
setShowHostManager(true);
}, []);
// 选中主机 - 右侧面板编辑
const handleSelectHost = useCallback((host) => {
setSelectedHost(host);
}, []);
// 新增主机 - 右侧面板
const handleAddNewHost = useCallback(() => {
setSelectedHost({}); // 空对象表示新建
}, []);
const openHostManager = useCallback(() => {
setEditingHost(null);
setShowHostManager(true);
@ -126,112 +147,252 @@ function App() {
return (
<div className="h-screen flex flex-col gradient-bg">
<TitleBar />
{/* 桌面端显示标题栏 */}
{!isMobile && <TitleBar />}
{/* 移动端顶部栏 */}
{isMobile && (
<div className="h-14 bg-shell-surface/90 backdrop-blur-xl border-b border-shell-border flex items-center justify-between px-4 safe-area-top">
<div className="flex items-center gap-2">
<img src={process.env.PUBLIC_URL + '/icon.svg'} alt="EasyShell" className="w-8 h-8" />
<span className="text-shell-text font-semibold font-display">EASYSHELL</span>
</div>
<button
onClick={() => setShowServerConfig(true)}
className="p-2 rounded-lg bg-shell-card border border-shell-border text-shell-text-dim"
>
<span className="text-xs"></span>
</button>
</div>
)}
<div className="flex-1 flex overflow-hidden">
<Sidebar
hosts={hosts}
activeTabs={activeTabs}
activeTabId={activeTabId}
selectedHostId={selectedHost?.id}
onSelectTab={setActiveTabId}
onCloseTab={closeTab}
onConnectHost={connectHost}
onSelectHost={handleSelectHost}
onAddNewHost={handleAddNewHost}
onOpenHostManager={openHostManager}
onEditHost={handleEditHost}
onOpenSettings={openSettings}
isRemoteConnected={isRemoteConnected}
collapsed={sidebarCollapsed}
collapsed={isMobile ? true : sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
isMobile={isMobile}
onOpenServerConfig={() => setShowServerConfig(true)}
/>
<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
<div className="h-11 bg-shell-surface/80 backdrop-blur-xl border-b border-shell-border flex items-center px-3 gap-2 overflow-x-auto custom-scrollbar flex-shrink-0 relative">
{/* 背景装饰 */}
<div className="absolute inset-0 cyber-grid opacity-10 pointer-events-none" />
{activeTabs.map((tab, index) => (
<motion.div
key={tab.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
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
flex items-center gap-2 px-4 py-2 rounded-lg cursor-pointer
transition-all duration-200 group min-w-0 flex-shrink-0 relative overflow-hidden
${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'
? 'bg-gradient-to-r from-shell-accent/20 to-shell-accent/5 text-shell-accent border border-shell-accent/40'
: 'bg-shell-card/50 hover:bg-shell-card text-shell-text-dim hover:text-shell-text border border-shell-border hover:border-shell-accent/20'
}
`}
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]">
{/* 活动指示器 */}
{activeTabId === tab.id && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 bg-shell-accent/5 pointer-events-none"
/>
)}
{/* 连接状态 */}
<motion.span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
tab.connected ? 'bg-shell-success' : 'bg-shell-text-dim'
}`}
animate={tab.connected ? { scale: [1, 1.2, 1] } : {}}
transition={{ duration: 2, repeat: Infinity }}
style={tab.connected ? { boxShadow: '0 0 6px rgba(0, 255, 136, 0.6)' } : {}}
/>
{/* 标签名 */}
<span className="truncate text-sm font-medium max-w-[120px] relative z-10 font-display tracking-wide">
{tab.title}
</span>
<button
{/* 关闭按钮 */}
<motion.button
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className="opacity-0 group-hover:opacity-100 hover:text-shell-error transition-opacity ml-1"
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded flex items-center justify-center
hover:bg-shell-error/20 hover:text-shell-error transition-all ml-1"
>
×
</button>
</div>
</motion.button>
</motion.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"
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 relative min-w-0">
{activeTabs.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 cyber-grid opacity-20" />
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-shell-accent/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-shell-neon-purple/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] hex-pattern opacity-30" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center relative z-10"
>
{/* Logo */}
<motion.div
animate={{ y: [0, -8, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
className="mb-8"
>
添加主机
</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"
<div className="inline-block relative">
<img
src={process.env.PUBLIC_URL + '/icon.svg'}
alt="EasyShell"
className="w-24 h-24 mx-auto drop-shadow-[0_0_30px_rgba(0,245,255,0.4)]"
/>
<div className="absolute -inset-4 bg-shell-accent/10 rounded-3xl blur-xl -z-10" />
</div>
</motion.div>
{/* 标题 */}
<h2 className="text-3xl font-bold text-shell-text mb-2 font-display tracking-wider">
WELCOME TO <span className="text-shell-accent neon-text">EASYSHELL</span>
</h2>
<p className="text-shell-text-dim mb-8 font-display tracking-widest text-sm">
CYBERPUNK REMOTE SHELL TERMINAL
</p>
{/* 操作按钮 */}
<div className="flex gap-4 justify-center mb-8">
<motion.button
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={handleAddNewHost}
className="btn-cyber px-8 py-3 rounded-lg text-shell-accent font-display tracking-wide text-sm"
>
+ 添加主机
</motion.button>
<motion.button
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={openSettings}
className="px-8 py-3 bg-shell-card/50 border border-shell-border
rounded-lg text-shell-text-dim hover:text-shell-text
hover:border-shell-neon-purple/30 hover:bg-shell-neon-purple/10
transition-all font-display tracking-wide text-sm"
>
云端同步
</motion.button>
</div>
{/* 快捷键提示 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex items-center justify-center gap-2 text-shell-text-dim text-sm"
>
连接数据库
</button>
<span></span>
<kbd className="code-highlight px-3 py-1">Ctrl + K</kbd>
<span>打开命令面板</span>
</motion.div>
{/* 装饰性扫描线 */}
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 flex gap-1">
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="w-12 h-px bg-shell-accent/30"
animate={{ opacity: [0.2, 0.8, 0.2] }}
transition={{ duration: 2, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</div>
</motion.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}
onToggleInfoPanel={() => setShowInfoPanel(!showInfoPanel)}
onOpenSFTP={() => setShowSFTP(true)}
showInfoPanel={showInfoPanel}
/>
</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>
{/* 右侧主机信息面板 */}
<AnimatePresence>
{showInfoPanel && activeTabId && activeTabs.find(t => t.id === activeTabId) && (
<HostInfoPanel
hostId={activeTabs.find(t => t.id === activeTabId)?.hostId}
connectionId={activeTabId}
isConnected={activeTabs.find(t => t.id === activeTabId)?.connected}
onOpenSFTP={() => setShowSFTP(true)}
onClose={() => setShowInfoPanel(false)}
/>
)}
</AnimatePresence>
{/* 右侧主机编辑面板 */}
<AnimatePresence>
{selectedHost && (
<HostEditPanel
host={selectedHost}
onClose={() => setSelectedHost(null)}
onConnect={(host) => {
connectHost(host);
setSelectedHost(null);
}}
onUpdate={() => {
loadHosts();
}}
onDelete={() => {
loadHosts();
setSelectedHost(null);
}}
/>
)}
</AnimatePresence>
</div>
</div>
</div>
@ -279,6 +440,23 @@ function App() {
/>
)}
</AnimatePresence>
{/* SFTP 文件浏览器 */}
<AnimatePresence>
{showSFTP && activeTabId && activeTabs.find(t => t.id === activeTabId) && (
<SFTPBrowser
hostId={activeTabs.find(t => t.id === activeTabId)?.hostId}
isConnected={activeTabs.find(t => t.id === activeTabId)?.connected}
onClose={() => setShowSFTP(false)}
/>
)}
</AnimatePresence>
{/* 服务器配置 (移动端) */}
<ServerConfig
isOpen={showServerConfig}
onClose={() => setShowServerConfig(false)}
/>
</div>
);
}

View File

@ -0,0 +1,411 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion } from 'framer-motion';
import {
FiX,
FiServer,
FiCheck,
FiLoader,
FiKey,
FiEye,
FiEyeOff,
FiTrash2,
FiPlay,
FiSave,
} from 'react-icons/fi';
import { getAPI } from '../services/api';
const colors = [
'#00d4ff', '#3fb950', '#d29922', '#f85149', '#bc8cff',
'#56d4dd', '#ffa657', '#ff7b72', '#d2a8ff', '#ff2d95',
];
function HostEditPanel({ host, onClose, onConnect, onUpdate, onDelete }) {
const api = useMemo(() => getAPI(), []);
const [showPassword, setShowPassword] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
host: '',
port: 22,
username: '',
password: '',
privateKey: '',
groupName: '默认分组',
color: '#00d4ff',
description: '',
});
// 当 host 变化时更新表单
useEffect(() => {
if (host) {
setFormData({
name: host.name || '',
host: host.host || '',
port: host.port || 22,
username: host.username || '',
password: host.password || '',
privateKey: host.private_key || '',
groupName: host.group_name || '默认分组',
color: host.color || '#00d4ff',
description: host.description || '',
});
setTestResult(null);
}
}, [host]);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
if (host?.id) {
await api.hosts.update(host.id, formData);
} else {
await api.hosts.add(formData);
}
onUpdate && onUpdate();
setTestResult({ success: true, message: '保存成功!' });
} catch (error) {
console.error('保存失败:', error);
setTestResult({ success: false, message: '保存失败: ' + error.message });
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await api.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);
}
};
const handleDelete = async () => {
if (window.confirm('确定要删除这个主机吗?')) {
try {
await api.hosts.delete(host.id);
onDelete && onDelete();
onClose && onClose();
} catch (error) {
console.error('删除失败:', error);
}
}
};
const handleConnect = () => {
if (host && onConnect) {
onConnect(host);
}
};
return (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 400, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="h-full bg-shell-surface/95 backdrop-blur-xl border-l border-shell-border flex flex-col overflow-hidden relative"
>
{/* 背景装饰 */}
<div className="absolute inset-0 cyber-grid opacity-10 pointer-events-none" />
<div className="absolute top-0 right-0 w-40 h-40 bg-shell-accent/5 rounded-full blur-3xl pointer-events-none" />
{/* 头部 */}
<div className="px-4 py-3 border-b border-shell-border flex items-center justify-between relative z-10">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${formData.color}20`, border: `1px solid ${formData.color}40` }}
>
<FiServer size={18} style={{ color: formData.color }} />
</div>
<div>
<h3 className="text-sm font-semibold text-shell-text font-display tracking-wide">
{host?.id ? '编辑主机' : '新建主机'}
</h3>
<p className="text-xs text-shell-text-dim font-mono">
{formData.host || 'hostname'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
>
<FiX size={18} />
</button>
</div>
{/* 快捷操作 */}
{host?.id && (
<div className="px-4 py-3 border-b border-shell-border flex gap-2 relative z-10">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleConnect}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2
bg-shell-accent/20 border border-shell-accent/40 rounded-lg
text-shell-accent hover:bg-shell-accent/30 transition-all text-sm font-medium"
>
<FiPlay size={14} />
连接终端
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleDelete}
className="p-2 rounded-lg bg-shell-error/10 border border-shell-error/30
text-shell-error hover:bg-shell-error/20 transition-all"
title="删除主机"
>
<FiTrash2 size={16} />
</motion.button>
</div>
)}
{/* 表单 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4 relative z-10">
{/* 名称 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
名称 *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
placeholder="生产服务器"
/>
</div>
{/* 主机地址和端口 */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
主机地址 *
</label>
<input
type="text"
required
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm font-mono placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
端口
</label>
<input
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 22 })}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm font-mono placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
/>
</div>
</div>
{/* 用户名 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
用户名 *
</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm font-mono placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
placeholder="root"
/>
</div>
{/* 密码 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
密码
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 pr-10 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm font-mono placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-shell-text-dim
hover:text-shell-text transition-colors"
>
{showPassword ? <FiEyeOff size={16} /> : <FiEye size={16} />}
</button>
</div>
</div>
{/* 私钥 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
<span className="flex items-center gap-1.5">
<FiKey size={12} />
SSH 私钥 (可选)
</span>
</label>
<textarea
value={formData.privateKey}
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-xs font-mono placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all resize-none"
placeholder="-----BEGIN RSA PRIVATE KEY-----..."
/>
</div>
{/* 分组 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
分组
</label>
<input
type="text"
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all"
placeholder="默认分组"
/>
</div>
{/* 颜色选择 */}
<div>
<label className="block text-xs font-medium text-shell-text-dim mb-2 uppercase tracking-wider">
标识颜色
</label>
<div className="flex gap-2 flex-wrap">
{colors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData({ ...formData, color })}
className={`w-7 h-7 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-xs font-medium text-shell-text-dim mb-1.5 uppercase tracking-wider">
备注说明
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-shell-bg border border-shell-border rounded-lg
text-shell-text text-sm placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 transition-all resize-none"
placeholder="关于这台服务器的备注..."
/>
</div>
{/* 测试结果 */}
{testResult && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-3 rounded-lg border text-sm ${
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={16} /> : <FiX size={16} />}
<span>{testResult.message}</span>
</div>
</motion.div>
)}
</form>
{/* 底部按钮 */}
<div className="px-4 py-3 border-t border-shell-border flex items-center gap-2 relative z-10">
<motion.button
type="button"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleTest}
disabled={testing || !formData.host || !formData.username}
className="flex items-center gap-2 px-3 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 text-sm"
>
{testing ? (
<FiLoader className="animate-spin" size={14} />
) : (
<FiCheck size={14} />
)}
测试
</motion.button>
<motion.button
type="submit"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSubmit}
disabled={saving || !formData.name || !formData.host || !formData.username}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2
bg-shell-accent rounded-lg text-white font-medium text-sm
hover:bg-shell-accent/80 disabled:opacity-50 disabled:cursor-not-allowed
transition-all"
>
{saving ? (
<FiLoader className="animate-spin" size={14} />
) : (
<FiSave size={14} />
)}
{host?.id ? '保存修改' : '创建主机'}
</motion.button>
</div>
</motion.div>
);
}
export default HostEditPanel;

View File

@ -0,0 +1,388 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FiServer, FiCpu, FiHardDrive, FiActivity, FiClock,
FiUser, FiGlobe, FiTerminal, FiFolder, FiRefreshCw,
FiChevronRight, FiX, FiZap
} from 'react-icons/fi';
function HostInfoPanel({ hostId, connectionId, isConnected, onOpenSFTP, onClose }) {
const [hostInfo, setHostInfo] = useState(null);
const [systemInfo, setSystemInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState('info'); // 'info' | 'system'
// 加载主机基本信息
const loadHostInfo = useCallback(async () => {
if (!hostId) return;
try {
const host = await window.electronAPI.hosts.getById(hostId);
setHostInfo(host);
} catch (err) {
console.error('加载主机信息失败:', err);
}
}, [hostId]);
// 获取系统信息
const fetchSystemInfo = useCallback(async () => {
if (!connectionId || !isConnected || !hostInfo) return;
setRefreshing(true);
try {
const result = await window.electronAPI.ssh.exec(
{
host: hostInfo.host,
port: hostInfo.port,
username: hostInfo.username,
password: hostInfo.password,
privateKey: hostInfo.private_key,
},
`
echo "===HOSTNAME===$(hostname)"
echo "===OS===$(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || uname -s)"
echo "===KERNEL===$(uname -r)"
echo "===UPTIME===$(uptime -p 2>/dev/null || uptime | awk -F'up ' '{print $2}' | awk -F',' '{print $1}')"
echo "===CPU===$(grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d':' -f2 | xargs || sysctl -n machdep.cpu.brand_string 2>/dev/null)"
echo "===CPU_CORES===$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)"
echo "===MEMORY===$(free -h 2>/dev/null | awk '/^Mem:/ {print $2}' || echo 'N/A')"
echo "===MEMORY_USED===$(free -h 2>/dev/null | awk '/^Mem:/ {print $3}' || echo 'N/A')"
echo "===DISK===$(df -h / | awk 'NR==2 {print $2}')"
echo "===DISK_USED===$(df -h / | awk 'NR==2 {print $3}')"
echo "===DISK_PERCENT===$(df -h / | awk 'NR==2 {print $5}')"
echo "===LOAD===$(cat /proc/loadavg 2>/dev/null | awk '{print $1, $2, $3}' || uptime | awk -F'load average:' '{print $2}' | xargs)"
echo "===IP===$(hostname -I 2>/dev/null | awk '{print $1}' || ifconfig 2>/dev/null | grep 'inet ' | grep -v 127.0.0.1 | head -1 | awk '{print $2}')"
`
);
if (result.stdout) {
const parseValue = (key) => {
const match = result.stdout.match(new RegExp(`===${key}===(.+)`));
return match ? match[1].trim() : 'N/A';
};
setSystemInfo({
hostname: parseValue('HOSTNAME'),
os: parseValue('OS'),
kernel: parseValue('KERNEL'),
uptime: parseValue('UPTIME'),
cpu: parseValue('CPU'),
cpuCores: parseValue('CPU_CORES'),
memory: parseValue('MEMORY'),
memoryUsed: parseValue('MEMORY_USED'),
disk: parseValue('DISK'),
diskUsed: parseValue('DISK_USED'),
diskPercent: parseValue('DISK_PERCENT'),
load: parseValue('LOAD'),
ip: parseValue('IP'),
});
}
} catch (err) {
console.error('获取系统信息失败:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [connectionId, isConnected, hostInfo]);
useEffect(() => {
loadHostInfo();
}, [loadHostInfo]);
useEffect(() => {
if (hostInfo && isConnected) {
fetchSystemInfo();
}
}, [hostInfo, isConnected, fetchSystemInfo]);
// 计算使用率百分比
const getUsagePercent = (used, total) => {
if (!used || !total || used === 'N/A' || total === 'N/A') return 0;
const usedNum = parseFloat(used);
const totalNum = parseFloat(total);
if (isNaN(usedNum) || isNaN(totalNum) || totalNum === 0) return 0;
return Math.min(100, Math.round((usedNum / totalNum) * 100));
};
const InfoCard = ({ icon: Icon, label, value, subValue }) => (
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
<div className="flex items-center gap-2 mb-1">
<Icon size={14} className="text-shell-accent" />
<span className="text-shell-text-dim text-xs">{label}</span>
</div>
<div className="text-shell-text font-medium text-sm truncate" title={value}>
{value || 'N/A'}
</div>
{subValue && (
<div className="text-shell-text-dim text-xs mt-1">{subValue}</div>
)}
</div>
);
const UsageBar = ({ label, used, total, percent, color = 'shell-accent' }) => (
<div className="mb-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-shell-text-dim">{label}</span>
<span className="text-shell-text">{used} / {total}</span>
</div>
<div className="h-2 bg-shell-border/50 rounded-full overflow-hidden">
<motion.div
className={`h-full bg-${color} rounded-full`}
initial={{ width: 0 }}
animate={{ width: `${percent}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{
backgroundColor: percent > 80 ? '#f85149' : percent > 60 ? '#d29922' : '#58a6ff'
}}
/>
</div>
</div>
);
return (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 340, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="h-full bg-shell-surface/90 backdrop-blur-xl border-l border-shell-border flex flex-col overflow-hidden relative"
>
{/* 背景装饰 */}
<div className="absolute inset-0 hex-pattern opacity-20 pointer-events-none" />
<div className="absolute top-0 right-0 w-32 h-32 bg-shell-accent/5 rounded-full blur-3xl pointer-events-none" />
{/* 头部 */}
<div className="h-11 px-4 flex items-center justify-between border-b border-shell-border flex-shrink-0 relative z-10">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-shell-accent/10 border border-shell-accent/30">
<FiZap size={14} className="text-shell-accent" />
</div>
<span className="text-shell-text font-semibold text-sm font-display tracking-wide">HOST INFO</span>
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 rounded-lg bg-shell-card/50 border border-shell-border
text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30 transition-all"
>
<FiX size={14} />
</motion.button>
</div>
{/* 标签切换 */}
<div className="flex border-b border-shell-border relative z-10">
<button
onClick={() => setActiveTab('info')}
className={`flex-1 py-2.5 text-sm font-medium transition-all font-display tracking-wide relative ${
activeTab === 'info'
? 'text-shell-accent'
: 'text-shell-text-dim hover:text-shell-text'
}`}
>
BASIC
{activeTab === 'info' && (
<motion.div
layoutId="panelTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-shell-accent"
style={{ boxShadow: '0 0 10px rgba(0, 212, 255, 0.5)' }}
/>
)}
</button>
<button
onClick={() => setActiveTab('system')}
className={`flex-1 py-2.5 text-sm font-medium transition-all font-display tracking-wide relative ${
activeTab === 'system'
? 'text-shell-accent'
: 'text-shell-text-dim hover:text-shell-text'
}`}
>
SYSTEM
{activeTab === 'system' && (
<motion.div
layoutId="panelTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-shell-accent"
style={{ boxShadow: '0 0 10px rgba(0, 212, 255, 0.5)' }}
/>
)}
</button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
<AnimatePresence mode="wait">
{activeTab === 'info' ? (
<motion.div
key="info"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="space-y-3"
>
{/* 连接状态 */}
<div className="bg-shell-card/50 rounded-lg p-4 border border-shell-border/50">
<div className="flex items-center gap-3 mb-3">
<div className={`w-3 h-3 rounded-full ${isConnected ? 'status-online' : 'status-offline'}`} />
<span className="text-shell-text font-medium">
{isConnected ? '已连接' : '未连接'}
</span>
</div>
{hostInfo && (
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<FiTerminal size={14} className="text-shell-text-dim" />
<span className="text-shell-text-dim">名称:</span>
<span className="text-shell-text">{hostInfo.name}</span>
</div>
<div className="flex items-center gap-2">
<FiGlobe size={14} className="text-shell-text-dim" />
<span className="text-shell-text-dim">地址:</span>
<span className="text-shell-text font-mono">{hostInfo.host}:{hostInfo.port || 22}</span>
</div>
<div className="flex items-center gap-2">
<FiUser size={14} className="text-shell-text-dim" />
<span className="text-shell-text-dim">用户:</span>
<span className="text-shell-text">{hostInfo.username}</span>
</div>
</div>
)}
</div>
{/* 标签 */}
{hostInfo?.tags && (
<div className="flex flex-wrap gap-2">
{hostInfo.tags.split(',').filter(Boolean).map((tag, i) => (
<span
key={i}
className="px-2 py-1 bg-shell-accent/10 text-shell-accent text-xs rounded-full"
>
{tag.trim()}
</span>
))}
</div>
)}
{/* 描述 */}
{hostInfo?.description && (
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
<div className="text-shell-text-dim text-xs mb-1">描述</div>
<div className="text-shell-text text-sm">{hostInfo.description}</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="system"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="space-y-4"
>
{/* 刷新按钮 */}
<div className="flex justify-end">
<button
onClick={fetchSystemInfo}
disabled={refreshing || !isConnected}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
text-shell-text-dim hover:text-shell-text
hover:bg-shell-card transition-colors disabled:opacity-50"
>
<FiRefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
刷新
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-shell-accent border-t-transparent rounded-full animate-spin" />
</div>
) : !isConnected ? (
<div className="text-center py-8 text-shell-text-dim text-sm">
请先连接主机
</div>
) : systemInfo ? (
<>
{/* 系统基本信息 */}
<div className="grid grid-cols-2 gap-2">
<InfoCard icon={FiServer} label="主机名" value={systemInfo.hostname} />
<InfoCard icon={FiGlobe} label="IP 地址" value={systemInfo.ip} />
</div>
<InfoCard icon={FiTerminal} label="操作系统" value={systemInfo.os} subValue={`Kernel: ${systemInfo.kernel}`} />
<InfoCard icon={FiClock} label="运行时间" value={systemInfo.uptime} />
{/* CPU 信息 */}
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
<div className="flex items-center gap-2 mb-2">
<FiCpu size={14} className="text-shell-cyan" />
<span className="text-shell-text-dim text-xs">CPU</span>
</div>
<div className="text-shell-text text-sm truncate" title={systemInfo.cpu}>
{systemInfo.cpu}
</div>
<div className="text-shell-text-dim text-xs mt-1">
{systemInfo.cpuCores} 核心 · 负载: {systemInfo.load}
</div>
</div>
{/* 内存使用 */}
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
<div className="flex items-center gap-2 mb-2">
<FiActivity size={14} className="text-shell-purple" />
<span className="text-shell-text-dim text-xs">内存</span>
</div>
<UsageBar
label="使用率"
used={systemInfo.memoryUsed}
total={systemInfo.memory}
percent={getUsagePercent(systemInfo.memoryUsed, systemInfo.memory)}
/>
</div>
{/* 磁盘使用 */}
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
<div className="flex items-center gap-2 mb-2">
<FiHardDrive size={14} className="text-shell-orange" />
<span className="text-shell-text-dim text-xs">磁盘 (/)</span>
</div>
<UsageBar
label="使用率"
used={systemInfo.diskUsed}
total={systemInfo.disk}
percent={parseInt(systemInfo.diskPercent) || 0}
/>
</div>
</>
) : (
<div className="text-center py-8 text-shell-text-dim text-sm">
无法获取系统信息
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* 底部操作 */}
<div className="p-4 border-t border-shell-border flex-shrink-0 relative z-10">
<motion.button
whileHover={{ scale: 1.02, y: -1 }}
whileTap={{ scale: 0.98 }}
onClick={onOpenSFTP}
disabled={!isConnected}
className="w-full btn-cyber flex items-center justify-center gap-2 px-4 py-3
rounded-lg text-shell-accent font-display tracking-wide text-sm
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:transform-none"
>
<FiFolder size={16} />
OPEN SFTP MANAGER
<FiChevronRight size={14} />
</motion.button>
</div>
</motion.div>
);
}
export default HostInfoPanel;

View File

@ -3,7 +3,6 @@ import { motion } from 'framer-motion';
import {
FiX,
FiPlus,
FiEdit2,
FiTrash2,
FiServer,
FiCheck,
@ -11,6 +10,7 @@ import {
FiKey,
FiEye,
FiEyeOff,
FiPlay,
} from 'react-icons/fi';
const colors = [
@ -141,9 +141,15 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
>
{/* 头部 */}
<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>
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-shell-text font-display">主机管理</h2>
{isEditing && (
<span className="px-2 py-0.5 bg-shell-accent/20 border border-shell-accent/30
rounded text-xs text-shell-accent font-medium">
{editingHost?.id ? '编辑中' : '新建'}
</span>
)}
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
@ -171,57 +177,67 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
</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}
{hosts.map((host) => {
const isSelected = editingHost?.id === host.id;
return (
<div
key={host.id}
onClick={() => {
setEditingHost(host);
setIsEditing(true);
setTestResult(null);
}}
className={`group p-3 rounded-lg border transition-all cursor-pointer
${isSelected
? 'bg-shell-accent/10 border-shell-accent/50'
: 'bg-shell-card/50 border-shell-border hover:border-shell-accent/30 hover:bg-shell-card'
}`}
>
<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="text-xs text-shell-text-dim truncate">
{host.username}@{host.host}:{host.port}
<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>
<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 className="flex items-center gap-2 mt-3 pt-3 border-t border-shell-border/50">
<button
onClick={(e) => {
e.stopPropagation();
onConnect(host);
}}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5
bg-shell-accent/20 text-shell-accent text-sm
rounded-md hover:bg-shell-accent/30 transition-colors"
>
<FiPlay size={12} />
连接
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id);
}}
className="p-1.5 rounded-md hover:bg-shell-error/20 text-shell-text-dim
hover:text-shell-error transition-colors"
title="删除主机"
>
<FiTrash2 size={14} />
</button>
</div>
</div>
</div>
))}
);
})}
{hosts.length === 0 && (
<div className="text-center py-8 text-shell-text-dim">

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
/**
* 服务器配置组件 - 用于移动端配置后端服务器地址
*/
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FiServer, FiX, FiCheck, FiRefreshCw, FiWifi, FiWifiOff } from 'react-icons/fi';
import { serverConfig, platform } from '../services/api';
function ServerConfig({ isOpen, onClose }) {
const [serverUrl, setServerUrl] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
useEffect(() => {
if (isOpen) {
setServerUrl(serverConfig.getUrl());
setIsConnected(serverConfig.isConnected());
}
}, [isOpen]);
// 测试连接
const testConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
const response = await fetch(`${serverUrl}/health`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
setTestResult({ success: true, message: '连接成功!服务器状态正常' });
} else {
setTestResult({ success: false, message: '服务器响应异常' });
}
} catch (error) {
setTestResult({ success: false, message: `连接失败: ${error.message}` });
} finally {
setIsTesting(false);
}
};
// 保存配置
const saveConfig = () => {
serverConfig.setUrl(serverUrl);
serverConfig.reconnect();
setTestResult({ success: true, message: '配置已保存!正在重新连接...' });
setTimeout(() => {
setIsConnected(serverConfig.isConnected());
}, 1000);
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-md bg-shell-surface border border-shell-border rounded-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="h-14 px-5 flex items-center justify-between border-b border-shell-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-shell-accent/10 border border-shell-accent/30">
<FiServer size={18} className="text-shell-accent" />
</div>
<div>
<h3 className="text-shell-text font-semibold font-display">服务器配置</h3>
<p className="text-shell-text-dim text-xs">配置 EasyShell 后端服务器</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
>
<FiX size={18} />
</button>
</div>
{/* 内容 */}
<div className="p-5 space-y-5">
{/* 当前平台信息 */}
<div className="p-3 rounded-lg bg-shell-card/50 border border-shell-border">
<div className="flex items-center justify-between text-sm">
<span className="text-shell-text-dim">当前平台</span>
<span className="text-shell-accent font-mono">
{platform.isElectron() ? 'Electron (桌面)' :
platform.isCapacitor() ? 'Capacitor (移动)' : 'Web'}
</span>
</div>
<div className="flex items-center justify-between text-sm mt-2">
<span className="text-shell-text-dim">连接状态</span>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<FiWifi size={14} className="text-shell-success" />
<span className="text-shell-success">已连接</span>
</>
) : (
<>
<FiWifiOff size={14} className="text-shell-error" />
<span className="text-shell-error">未连接</span>
</>
)}
</div>
</div>
</div>
{/* 服务器地址输入 */}
<div>
<label className="block text-sm text-shell-text-dim mb-2">
服务器地址
</label>
<input
type="url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="http://192.168.1.100:3001"
className="w-full px-4 py-3 bg-shell-bg border border-shell-border rounded-lg
text-shell-text font-mono text-sm
focus:border-shell-accent focus:outline-none transition-colors"
/>
<p className="mt-2 text-xs text-shell-text-dim">
请输入运行 EasyShell Server 的服务器地址
</p>
</div>
{/* 测试结果 */}
{testResult && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-3 rounded-lg ${
testResult.success
? 'bg-shell-success/10 border border-shell-success/30 text-shell-success'
: 'bg-shell-error/10 border border-shell-error/30 text-shell-error'
}`}
>
<div className="flex items-center gap-2 text-sm">
{testResult.success ? <FiCheck size={16} /> : <FiX size={16} />}
{testResult.message}
</div>
</motion.div>
)}
{/* 操作按钮 */}
<div className="flex gap-3">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={testConnection}
disabled={isTesting || !serverUrl}
className="flex-1 flex items-center justify-center gap-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FiRefreshCw size={16} className={isTesting ? 'animate-spin' : ''} />
测试连接
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={saveConfig}
disabled={!serverUrl}
className="flex-1 btn-cyber flex items-center justify-center gap-2 py-3
rounded-lg text-shell-accent font-medium
disabled:opacity-50 disabled:cursor-not-allowed"
>
<FiCheck size={16} />
保存配置
</motion.button>
</div>
{/* 帮助信息 */}
<div className="p-4 rounded-lg bg-shell-accent/5 border border-shell-accent/20">
<h4 className="text-shell-accent text-sm font-medium mb-2">💡 使用说明</h4>
<ul className="text-shell-text-dim text-xs space-y-1">
<li>1. 在电脑上运行 <code className="code-highlight">npm run server</code></li>
<li>2. 确保手机和电脑在同一局域网</li>
<li>3. 输入电脑的 IP 地址和端口 (默认 3001)</li>
<li>4. 点击测试连接验证配置</li>
</ul>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
export default ServerConfig;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import {
FiServer,
FiPlus,
@ -9,18 +9,20 @@ import {
FiChevronLeft,
FiChevronRight,
FiTerminal,
FiEdit2,
FiZap,
} from 'react-icons/fi';
function Sidebar({
hosts,
activeTabs,
activeTabId,
selectedHostId,
onSelectTab,
onCloseTab,
onConnectHost,
onSelectHost,
onAddNewHost,
onOpenHostManager,
onEditHost,
onOpenSettings,
isRemoteConnected,
collapsed,
@ -37,189 +39,314 @@ function Sidebar({
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"
animate={{ width: collapsed ? 60 : 280 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="bg-shell-surface/80 backdrop-blur-xl border-r border-shell-border flex flex-col h-full overflow-hidden relative"
>
{/* 背景装饰 */}
<div className="absolute inset-0 hex-pattern opacity-30 pointer-events-none" />
<div className="absolute top-0 left-0 w-32 h-32 bg-shell-accent/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 right-0 w-24 h-24 bg-shell-neon-purple/5 rounded-full blur-3xl pointer-events-none" />
{/* 顶部操作区 */}
<div className="p-3 border-b border-shell-border flex-shrink-0">
<div className="p-3 border-b border-shell-border flex-shrink-0 relative z-10">
<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>
)}
<AnimatePresence>
{!collapsed && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="flex items-center gap-2"
>
<FiZap className="text-shell-accent" size={16} />
<span className="text-sm font-semibold text-shell-text font-display tracking-wide">
主机列表
</span>
</motion.div>
)}
</AnimatePresence>
<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"
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={onAddNewHost}
className="p-2 rounded-lg bg-shell-accent/10 border border-shell-accent/30
text-shell-accent hover:bg-shell-accent/20 hover:border-shell-accent/50
transition-all duration-200"
title="添加主机"
>
<FiPlus size={18} />
</button>
<button
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onToggleCollapse}
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
hover:text-shell-text transition-colors"
hover:text-shell-text transition-all border border-transparent
hover:border-shell-border"
title={collapsed ? '展开' : '收起'}
>
{collapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
</button>
</motion.button>
</div>
</div>
</div>
{/* 主机列表 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 relative z-10">
{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) => {
<AnimatePresence>
{!collapsed && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="px-3 py-2 flex items-center gap-2"
>
<div className="h-px flex-1 bg-gradient-to-r from-shell-border to-transparent" />
<span className="text-[10px] font-semibold text-shell-text-dim uppercase tracking-widest font-display">
{groupName}
</span>
<div className="h-px flex-1 bg-gradient-to-l from-shell-border to-transparent" />
</motion.div>
)}
</AnimatePresence>
{groupHosts.map((host, index) => {
const isActive = activeTabs.some((t) => t.hostId === host.id);
const isConnected = activeTabs.some((t) => t.hostId === host.id && t.connected);
const isSelected = selectedHostId === host.id;
return (
<motion.div
key={host.id}
whileHover={{ x: collapsed ? 0 : 2 }}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ x: collapsed ? 0 : 4 }}
onClick={() => onSelectHost && onSelectHost(host)}
onDoubleClick={() => onConnectHost(host)}
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'
flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer mb-1.5
transition-all duration-200 group relative overflow-hidden
${isSelected
? 'bg-gradient-to-r from-shell-neon-purple/20 to-transparent border border-shell-neon-purple/40'
: isActive
? 'bg-gradient-to-r from-shell-accent/15 to-transparent border border-shell-accent/30'
: 'hover:bg-shell-card/80 border border-transparent hover:border-shell-border'
}
`}
>
{/* 点击连接 */}
<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` }}
{/* 选中指示线 */}
{isSelected && (
<motion.div
layoutId="selectedIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 rounded-r-full bg-shell-neon-purple"
style={{ boxShadow: '0 0 10px rgba(188, 140, 255, 0.5)' }}
/>
)}
{/* 活动指示线 */}
{isActive && !isSelected && (
<motion.div
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 rounded-r-full bg-shell-accent"
style={{ boxShadow: '0 0 10px rgba(0, 212, 255, 0.5)' }}
/>
)}
{/* 主机内容 */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 主机图标 */}
<motion.div
whileHover={{ rotate: [0, -10, 10, 0] }}
transition={{ duration: 0.5 }}
className={`
w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0
relative overflow-hidden
`}
style={{
backgroundColor: `${host.color || '#00d4ff'}15`,
border: `1px solid ${host.color || '#00d4ff'}30`
}}
>
<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>
)}
<FiServer size={16} style={{ color: host.color || '#00d4ff' }} />
{/* 连接状态光点 */}
{isConnected && (
<motion.div
className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-shell-success"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity }}
style={{ boxShadow: '0 0 6px rgba(0, 255, 136, 0.6)' }}
/>
)}
</motion.div>
<AnimatePresence>
{!collapsed && (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
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 font-mono">
{host.username}@{host.host}
</div>
</motion.div>
)}
</AnimatePresence>
</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" />
)}
{/* 快速连接按钮 */}
<AnimatePresence>
{!collapsed && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0 }}
whileHover={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={(e) => {
e.stopPropagation();
onConnectHost && onConnectHost(host);
}}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md
bg-shell-accent/20 border border-shell-accent/30
text-shell-accent hover:bg-shell-accent/30
transition-all flex-shrink-0"
title="快速连接"
>
<FiTerminal size={13} />
</motion.button>
)}
</AnimatePresence>
</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"
{/* 空状态 */}
{hosts.length === 0 && (
<div className="text-center py-12">
<motion.div
animate={{ y: [0, -5, 0] }}
transition={{ duration: 3, repeat: Infinity }}
className="mb-4"
>
添加第一个主机
</button>
<FiTerminal className="mx-auto text-4xl text-shell-text-dim opacity-30" />
</motion.div>
{!collapsed && (
<>
<p className="text-sm text-shell-text-dim mb-4">暂无主机</p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onAddNewHost}
className="btn-cyber px-4 py-2 rounded-lg text-sm text-shell-accent font-medium"
>
+ 添加第一个主机
</motion.button>
</>
)}
</div>
)}
{/* 折叠状态下的空状态提示 */}
{hosts.length === 0 && collapsed && (
<div className="text-center py-4">
<button
{/* 主机管理入口 */}
{hosts.length > 0 && !collapsed && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="px-3 py-2 mt-2"
>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onOpenHostManager}
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim"
title="添加主机"
className="w-full flex items-center justify-center gap-2 px-3 py-2
bg-shell-card/30 border border-shell-border/50 rounded-lg
text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30
hover:bg-shell-card/50 transition-all text-xs"
>
<FiTerminal size={20} />
</button>
</div>
<FiServer size={12} />
<span>主机管理</span>
</motion.button>
</motion.div>
)}
</div>
{/* 底部状态栏 */}
<div className="p-3 border-t border-shell-border flex-shrink-0">
<div className="p-3 border-t border-shell-border flex-shrink-0 relative z-10">
<div className={`flex items-center ${collapsed ? 'flex-col gap-2' : 'justify-between'}`}>
{/* 数据库连接状态 */}
<div
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`
flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer
transition-colors ${collapsed ? 'justify-center w-full' : ''}
flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer
transition-all duration-200 ${collapsed ? 'justify-center w-full' : ''}
${isRemoteConnected
? 'bg-shell-success/10 text-shell-success'
: 'bg-shell-card text-shell-text-dim hover:text-shell-text'
? 'bg-shell-success/10 border border-shell-success/30 text-shell-success'
: 'bg-shell-card/50 border border-shell-border text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30'
}
`}
onClick={onOpenSettings}
title={isRemoteConnected ? '已连接远程数据库' : '未连接远程数据库'}
>
{isRemoteConnected ? <FiCloud size={16} /> : <FiCloudOff size={16} />}
{!collapsed && (
<span className="text-xs font-medium">
{isRemoteConnected ? '已同步' : '本地模式'}
</span>
{isRemoteConnected ? (
<>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 20, repeat: Infinity, ease: 'linear' }}
>
<FiCloud size={16} />
</motion.div>
{!collapsed && (
<span className="text-xs font-medium">云端同步</span>
)}
</>
) : (
<>
<FiCloudOff size={16} />
{!collapsed && (
<span className="text-xs font-medium">本地模式</span>
)}
</>
)}
</div>
</motion.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>
)}
{/* 设置按钮 */}
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400 }}
onClick={onOpenSettings}
className={`p-2 rounded-lg bg-shell-card/50 border border-shell-border
text-shell-text-dim hover:text-shell-accent
hover:border-shell-accent/30 hover:bg-shell-accent/10
transition-colors ${collapsed ? 'w-full flex justify-center' : ''}`}
title="设置"
>
<FiSettings size={18} />
</motion.button>
</div>
{/* 版权信息 */}
{!collapsed && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 text-center"
>
<span className="text-[9px] text-shell-text-muted font-mono tracking-wider">
© 2024 EASYSHELL · CYBERPUNK EDITION
</span>
</motion.div>
)}
</div>
</motion.div>
);

View File

@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
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';
import { FiCommand, FiRefreshCw, FiInfo, FiFolder, FiActivity, FiZap } from 'react-icons/fi';
function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onToggleInfoPanel, onOpenSFTP, showInfoPanel }) {
const containerRef = useRef(null);
const terminalRef = useRef(null);
const xtermRef = useRef(null);
@ -15,10 +16,9 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
const isConnectingRef = useRef(false);
const isMountedRef = useRef(true);
const initTimerRef = useRef(null);
const hasConnectedRef = useRef(false); // 防止重复连接
const hasConnectedRef = useRef(false);
const resizeObserverRef = useRef(null);
// 用 ref 存储回调,避免作为依赖
const onConnectionChangeRef = useRef(onConnectionChange);
onConnectionChangeRef.current = onConnectionChange;
@ -27,9 +27,8 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
const [error, setError] = useState(null);
const [isReady, setIsReady] = useState(false);
// 连接SSH - 不依赖 onConnectionChange
// 连接SSH
const connect = useCallback(async () => {
// 防止重复连接
if (!window.electronAPI || !hostId || isConnectingRef.current || connectionIdRef.current || hasConnectedRef.current) {
return;
}
@ -68,16 +67,15 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
const removeCloseListener = window.electronAPI.ssh.onClose(result.connectionId, () => {
if (isMountedRef.current) {
onConnectionChangeRef.current?.(false);
xtermRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m');
xtermRef.current?.writeln('\r\n\x1b[38;2;255;208;0m⚡ 连接已断开\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`);
xtermRef.current?.writeln(`\r\n\x1b[38;2;255;51;102m错误: ${err}\x1b[0m`);
});
cleanupListenersRef.current = () => {
@ -86,7 +84,6 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
removeErrorListener();
};
// 延迟一点再发送终端尺寸,确保 shell 准备好
setTimeout(() => {
if (fitAddonRef.current && xtermRef.current) {
try {
@ -112,7 +109,7 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
if (isMountedRef.current) {
setError(err.message);
onConnectionChangeRef.current?.(false);
xtermRef.current?.writeln(`\x1b[31m连接失败: ${err.message}\x1b[0m`);
xtermRef.current?.writeln(`\x1b[38;2;255;51;102m连接失败: ${err.message}\x1b[0m`);
}
} finally {
isConnectingRef.current = false;
@ -120,7 +117,7 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
setIsConnecting(false);
}
}
}, [hostId]); // 只依赖 hostId
}, [hostId]);
// 调整终端尺寸
const fitTerminal = useCallback(() => {
@ -129,7 +126,6 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
try {
fitAddonRef.current.fit();
// 通知 SSH 服务器尺寸变化
if (connectionIdRef.current && window.electronAPI) {
window.electronAPI.ssh.resize(
connectionIdRef.current,
@ -142,12 +138,11 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
}
}, []);
// 初始化终端
// 初始化终端 - 赛博朋克主题
const initTerminal = useCallback(() => {
if (!terminalRef.current || xtermRef.current) return false;
const container = terminalRef.current;
// 确保容器有有效尺寸
if (container.clientWidth < 100 || container.clientHeight < 100) {
return false;
}
@ -158,58 +153,57 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
cursorStyle: 'bar',
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
lineHeight: 1.4,
scrollback: 1000,
lineHeight: 1.5,
scrollback: 2000,
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',
// 赛博朋克主题配色
background: '#050810',
foreground: '#e8f0ff',
cursor: '#00d4ff',
cursorAccent: '#050810',
selectionBackground: 'rgba(0, 212, 255, 0.25)',
selectionForeground: '#ffffff',
// 基础色
black: '#0a0f18',
red: '#ff3366',
green: '#00ff88',
yellow: '#ffd000',
blue: '#00d4ff',
magenta: '#a855f7',
cyan: '#00d4ff',
white: '#e8f0ff',
// 亮色
brightBlack: '#3d4a5c',
brightRed: '#ff6b8a',
brightGreen: '#5cffab',
brightYellow: '#ffe566',
brightBlue: '#5ce1ff',
brightMagenta: '#c084fc',
brightCyan: '#5ce1ff',
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();
});
@ -222,7 +216,6 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
}
}, [fitTerminal]);
// 等待容器就绪后初始化
useEffect(() => {
isMountedRef.current = true;
@ -231,19 +224,16 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
if (initTerminal()) {
setIsReady(true);
// 初始化成功后连接
setTimeout(() => {
if (isMountedRef.current) {
connect();
}
}, 100);
} else {
// 容器未就绪,继续尝试
initTimerRef.current = setTimeout(tryInit, 100);
}
};
// 延迟开始尝试初始化
initTimerRef.current = setTimeout(tryInit, 200);
return () => {
@ -276,7 +266,6 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
};
}, [initTerminal, connect]);
// 监听命令面板发送的命令
useEffect(() => {
const handleCommand = (e) => {
if (e.detail.tabId === tabId && connectionIdRef.current && window.electronAPI) {
@ -288,7 +277,6 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
return () => window.removeEventListener('terminal-command', handleCommand);
}, [tabId]);
// 重连
const handleReconnect = useCallback(() => {
if (cleanupListenersRef.current) {
cleanupListenersRef.current();
@ -299,11 +287,10 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
}
connectionIdRef.current = null;
isConnectingRef.current = false;
hasConnectedRef.current = false; // 重置连接标志
hasConnectedRef.current = false;
setConnectionId(null);
setError(null);
// 完全重置终端(清屏+重置光标位置)
if (xtermRef.current) {
xtermRef.current.reset();
}
@ -311,68 +298,133 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette }) {
setTimeout(() => connect(), 100);
}, [connect]);
// 工具栏按钮组件
const ToolButton = ({ onClick, disabled, active, title, children }) => (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClick}
disabled={disabled}
className={`
p-2 rounded-lg transition-all duration-200
${active
? 'bg-shell-accent/20 text-shell-accent border border-shell-accent/40'
: 'bg-shell-card/50 border border-shell-border text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30 hover:bg-shell-accent/10'
}
${disabled ? 'opacity-40 cursor-not-allowed' : ''}
`}
title={title}
>
{children}
</motion.button>
);
return (
<div ref={containerRef} className="h-full flex flex-col bg-shell-bg">
<div ref={containerRef} className="h-full flex flex-col bg-shell-bg relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 cyber-grid opacity-20 pointer-events-none" />
<div className="absolute top-0 right-0 w-64 h-64 bg-shell-accent/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-shell-neon-purple/5 rounded-full blur-3xl pointer-events-none" />
{/* 终端工具栏 */}
<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="h-12 bg-shell-surface/80 backdrop-blur-xl border-b border-shell-border flex items-center px-4 justify-between flex-shrink-0 relative z-10">
{/* 左侧状态 */}
<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 className="loader-cyber w-4 h-4" style={{ borderWidth: '2px' }} />
<span className="font-display tracking-wide">INITIALIZING</span>
</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" />
连接中...
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<FiActivity size={16} />
</motion.div>
<span className="font-display tracking-wide">CONNECTING</span>
</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" />
连接失败
<span className="w-2 h-2 rounded-full bg-shell-error" style={{ boxShadow: '0 0 8px rgba(255, 51, 102, 0.6)' }} />
<span className="font-display tracking-wide">CONNECTION FAILED</span>
</div>
) : connectionId ? (
<div className="flex items-center gap-2 text-shell-success text-sm">
<span className="w-2 h-2 rounded-full status-online" />
已连接
<motion.span
className="w-2 h-2 rounded-full bg-shell-success"
animate={{ scale: [1, 1.2, 1], opacity: [0.7, 1, 0.7] }}
transition={{ duration: 2, repeat: Infinity }}
style={{ boxShadow: '0 0 8px rgba(0, 255, 136, 0.6)' }}
/>
<span className="font-display tracking-wide">CONNECTED</span>
<FiZap size={14} className="text-shell-success" />
</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" />
未连接
<span className="font-display tracking-wide">OFFLINE</span>
</div>
)}
</div>
{/* 右侧工具按钮 */}
<div className="flex items-center gap-2">
<button
{/* 命令提示 */}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
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"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg btn-cyber text-sm text-shell-accent"
title="命令面板 (Ctrl+K)"
>
<FiCommand size={14} />
<span className="hidden sm:inline">命令提示</span>
</button>
<button
<span className="hidden sm:inline font-display tracking-wide">COMMANDS</span>
</motion.button>
<div className="divider-vertical h-6 mx-1" />
{/* SFTP */}
<ToolButton
onClick={onOpenSFTP}
disabled={!connectionId}
title="SFTP 文件管理器"
>
<FiFolder size={16} />
</ToolButton>
{/* 主机信息 */}
<ToolButton
onClick={onToggleInfoPanel}
active={showInfoPanel}
title="主机信息"
>
<FiInfo size={16} />
</ToolButton>
<div className="divider-vertical h-6 mx-1" />
{/* 重连 */}
<ToolButton
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>
</ToolButton>
</div>
</div>
{/* 终端内容 */}
<div
ref={terminalRef}
className="flex-1 p-2 terminal-container overflow-hidden"
className="flex-1 p-3 terminal-container overflow-hidden relative z-10"
style={{ minHeight: '300px', minWidth: '400px' }}
/>
{/* 底部装饰线 */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-shell-accent/20 to-transparent pointer-events-none" />
</div>
);
}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { FiMinus, FiSquare, FiX, FiMaximize2 } from 'react-icons/fi';
function TitleBar() {
@ -29,50 +30,113 @@ function TitleBar() {
};
return (
<div className="h-9 bg-shell-surface/80 border-b border-shell-border flex items-center justify-between px-4 drag-region">
<div className="h-10 bg-shell-surface/90 backdrop-blur-xl border-b border-shell-border flex items-center justify-between px-4 relative overflow-hidden">
{/* 拖动层 - 必须在最底层 */}
<div className="drag-region absolute inset-0 z-0" />
{/* 顶部装饰线 */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-shell-accent/30 to-transparent pointer-events-none" />
{/* 背景网格效果 */}
<div className="absolute inset-0 cyber-grid opacity-30 pointer-events-none" />
{/* 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 className="flex items-center gap-3 relative z-10 no-drag">
{/* 动态 Logo */}
<motion.div
className="relative"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src={process.env.PUBLIC_URL + '/icon.svg'}
alt="EasyShell"
className="w-7 h-7 drop-shadow-[0_0_8px_rgba(0,245,255,0.3)]"
/>
</motion.div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-shell-text font-display tracking-wider">
EASY<span className="text-shell-accent">SHELL</span>
</span>
{/* 版本徽章 */}
<span className="badge-cyber text-shell-accent">
v1.0
</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 className="flex items-center gap-2 ml-4 px-3 py-1 rounded-full bg-shell-card/50 border border-shell-border">
<motion.div
className="w-1.5 h-1.5 rounded-full bg-shell-success"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
/>
<span className="text-[10px] text-shell-text-dim font-mono uppercase tracking-wider">System Online</span>
</div>
</div>
{/* 中间装饰 - 扫描线 */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-1 pointer-events-none opacity-20">
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="w-8 h-px bg-shell-accent"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.1 }}
/>
))}
</div>
{/* 窗口控制按钮 */}
<div className="flex items-center gap-1 no-drag">
<button
<div className="flex items-center gap-1 relative z-10 no-drag">
{/* 最小化 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
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"
className="w-9 h-7 flex items-center justify-center rounded-md
bg-shell-card/50 border border-shell-border
text-shell-text-dim hover:text-shell-text
hover:border-shell-accent/30 hover:bg-shell-accent/10
transition-all duration-200"
title="最小化"
>
<FiMinus size={14} />
</button>
<button
</motion.button>
{/* 最大化/还原 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
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"
className="w-9 h-7 flex items-center justify-center rounded-md
bg-shell-card/50 border border-shell-border
text-shell-text-dim hover:text-shell-text
hover:border-shell-accent/30 hover:bg-shell-accent/10
transition-all duration-200"
title={isMaximized ? '还原' : '最大化'}
>
{isMaximized ? <FiMaximize2 size={12} /> : <FiSquare size={12} />}
</button>
<button
</motion.button>
{/* 关闭 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
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"
className="w-9 h-7 flex items-center justify-center rounded-md
bg-shell-card/50 border border-shell-border
text-shell-text-dim hover:text-white
hover:border-shell-error/50 hover:bg-shell-error/80
transition-all duration-200"
title="关闭"
>
<FiX size={14} />
</button>
</motion.button>
</div>
</div>
);
}
export default TitleBar;

View File

@ -2,15 +2,31 @@
@tailwind components;
@tailwind utilities;
/* 全局样式 */
* {
-webkit-app-region: no-drag;
/* ========================================
EasyShell - Cyberpunk Theme
赛博朋克科技风格主题
======================================== */
/* CSS 变量定义 */
:root {
--color-bg: #050810;
--color-surface: #0a0f18;
--color-card: #0f1520;
--color-border: #1a2332;
--color-accent: #00d4ff;
--color-accent-glow: rgba(0, 212, 255, 0.5);
--color-neon-pink: #ff2d95;
--color-neon-green: #00ff88;
--color-text: #e8f0ff;
--color-text-dim: #6b7a94;
}
/* 全局样式 */
body {
font-family: 'Space Grotesk', system-ui, sans-serif;
background: linear-gradient(135deg, #0a0e14 0%, #0d1117 50%, #161b22 100%);
color: #e6edf3;
font-family: 'Inter', 'Rajdhani', system-ui, sans-serif;
background: var(--color-bg);
color: var(--color-text);
overflow: hidden;
user-select: none;
}
@ -20,58 +36,208 @@ body {
font-family: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
}
/* 可拖拽区域 */
/* 显示字体 - 科技感 */
.font-display {
font-family: 'Rajdhani', 'Orbitron', system-ui, sans-serif;
letter-spacing: 0.05em;
}
/* 可拖拽区域 - Windows 窗口拖动 */
.drag-region {
-webkit-app-region: drag;
-webkit-app-region: drag !important;
app-region: drag !important;
}
.no-drag {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag !important;
app-region: no-drag !important;
}
/* 自定义滚动条 */
/* ========================================
滚动条样式
======================================== */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #30363d;
background: rgba(26, 35, 50, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #484f58;
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #1a2332 0%, #253244 100%);
border-radius: 3px;
border: 1px solid rgba(0, 212, 255, 0.1);
}
/* 玻璃态效果 */
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #253244 0%, #00d4ff33 100%);
border-color: rgba(0, 212, 255, 0.3);
}
/* ========================================
玻璃态效果
======================================== */
.glass {
background: rgba(22, 27, 34, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(48, 54, 61, 0.6);
background: rgba(10, 15, 24, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(26, 35, 50, 0.8);
}
.glass-hover:hover {
background: rgba(30, 36, 44, 0.9);
border-color: rgba(88, 166, 255, 0.3);
background: rgba(15, 21, 32, 0.9);
border-color: rgba(0, 212, 255, 0.3);
box-shadow: 0 0 20px rgba(0, 212, 255, 0.1);
}
/* 发光边框 */
.glass-card {
background: linear-gradient(135deg, rgba(15, 21, 32, 0.9) 0%, rgba(10, 15, 24, 0.95) 100%);
backdrop-filter: blur(16px);
border: 1px solid rgba(26, 35, 50, 0.6);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
/* ========================================
霓虹发光效果
======================================== */
.glow-border {
box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.3),
0 0 20px rgba(88, 166, 255, 0.1);
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.3),
0 0 20px rgba(0, 212, 255, 0.15),
inset 0 0 20px rgba(0, 212, 255, 0.05);
}
.glow-border-active {
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.5),
0 0 30px rgba(88, 166, 255, 0.2);
box-shadow:
0 0 0 2px rgba(0, 212, 255, 0.5),
0 0 30px rgba(0, 212, 255, 0.25),
inset 0 0 30px rgba(0, 212, 255, 0.1);
}
/* 输入框样式 */
.neon-text {
text-shadow:
0 0 10px currentColor,
0 0 20px currentColor,
0 0 40px currentColor;
}
.neon-box {
box-shadow:
0 0 5px currentColor,
0 0 15px currentColor,
0 0 30px currentColor;
}
/* ========================================
按钮效果
======================================== */
.btn-cyber {
position: relative;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.15) 0%, rgba(0, 168, 204, 0.1) 100%);
border: 1px solid rgba(0, 212, 255, 0.4);
transition: all 0.3s ease;
overflow: hidden;
}
.btn-cyber::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.2), transparent);
transition: left 0.5s ease;
}
.btn-cyber:hover {
border-color: rgba(0, 212, 255, 0.8);
box-shadow:
0 0 20px rgba(0, 212, 255, 0.3),
inset 0 0 20px rgba(0, 212, 255, 0.1);
transform: translateY(-2px);
}
.btn-cyber:hover::before {
left: 100%;
}
.btn-cyber:active {
transform: translateY(0);
}
.btn-glow {
transition: all 0.2s ease;
}
.btn-glow:hover {
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
transform: translateY(-1px);
}
.btn-glow:active {
transform: translateY(0);
}
/* 粉色霓虹按钮 */
.btn-neon-pink {
background: linear-gradient(135deg, rgba(255, 45, 149, 0.15) 0%, rgba(168, 85, 247, 0.1) 100%);
border: 1px solid rgba(255, 45, 149, 0.4);
}
.btn-neon-pink:hover {
border-color: rgba(255, 45, 149, 0.8);
box-shadow: 0 0 25px rgba(255, 45, 149, 0.4);
}
/* ========================================
卡片效果
======================================== */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.2);
}
.card-cyber {
position: relative;
background: linear-gradient(135deg, rgba(15, 21, 32, 0.95) 0%, rgba(10, 15, 24, 0.98) 100%);
border: 1px solid rgba(26, 35, 50, 0.8);
overflow: hidden;
}
.card-cyber::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
}
.card-cyber::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 45, 149, 0.3), transparent);
}
/* ========================================
输入框样式
======================================== */
input, textarea {
user-select: text;
}
@ -80,35 +246,45 @@ input:focus, textarea:focus {
outline: none;
}
/* 按钮悬停效果 */
.btn-glow {
transition: all 0.2s ease;
.input-cyber {
background: rgba(5, 8, 16, 0.8);
border: 1px solid rgba(26, 35, 50, 0.8);
transition: all 0.3s ease;
}
.btn-glow:hover {
box-shadow: 0 0 20px rgba(88, 166, 255, 0.4);
transform: translateY(-1px);
.input-cyber:focus {
border-color: rgba(0, 212, 255, 0.6);
box-shadow:
0 0 0 3px rgba(0, 212, 255, 0.1),
0 0 20px rgba(0, 212, 255, 0.1);
}
.btn-glow:active {
transform: translateY(0);
.input-cyber::placeholder {
color: #3d4a5c;
}
/* 卡片悬停效果 */
.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;
background: linear-gradient(180deg, #050810 0%, #0a0f18 100%);
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* 终端扫描线效果 */
.terminal-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.3), transparent);
animation: scan 4s linear infinite;
pointer-events: none;
z-index: 10;
}
.xterm {
@ -120,15 +296,35 @@ input:focus, textarea:focus {
}
.xterm-viewport::-webkit-scrollbar-track {
background: #161b22;
background: rgba(10, 15, 24, 0.5);
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: #30363d;
background: linear-gradient(180deg, #1a2332 0%, #00d4ff22 100%);
border-radius: 4px;
border: 1px solid rgba(0, 212, 255, 0.1);
}
/* ========================================
动画效果
======================================== */
@keyframes scan {
0% {
transform: translateY(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(calc(100vh - 2px));
opacity: 0;
}
}
/* 动画类 */
@keyframes shimmer {
0% {
background-position: -200% 0;
@ -142,13 +338,39 @@ input:focus, textarea:focus {
background: linear-gradient(
90deg,
transparent 0%,
rgba(88, 166, 255, 0.1) 50%,
rgba(0, 212, 255, 0.15) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
/* 流光边框 */
@keyframes borderFlow {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
.border-flow {
position: relative;
}
.border-flow::before {
content: '';
position: absolute;
inset: -1px;
background: linear-gradient(90deg, #00d4ff, #a855f7, #ff2d95, #00d4ff);
background-size: 200% 100%;
animation: borderFlow 3s linear infinite;
border-radius: inherit;
z-index: -1;
opacity: 0.6;
}
/* 脉冲点 */
.pulse-dot {
width: 8px;
@ -164,52 +386,366 @@ input:focus, textarea:focus {
}
50% {
opacity: 0.5;
transform: scale(1.2);
transform: scale(1.3);
}
}
/* 状态颜色 */
/* 呼吸灯效果 */
@keyframes breathe {
0%, 100% {
opacity: 0.4;
box-shadow: 0 0 10px currentColor;
}
50% {
opacity: 1;
box-shadow: 0 0 25px currentColor;
}
}
.breathe {
animation: breathe 3s ease-in-out infinite;
}
/* ========================================
状态指示器
======================================== */
.status-online {
background: #3fb950;
box-shadow: 0 0 8px rgba(63, 185, 80, 0.6);
background: #00ff88;
box-shadow:
0 0 8px rgba(0, 255, 136, 0.6),
0 0 20px rgba(0, 255, 136, 0.3);
}
.status-offline {
background: #8b949e;
background: #6b7a94;
}
.status-error {
background: #f85149;
box-shadow: 0 0 8px rgba(248, 81, 73, 0.6);
background: #ff3366;
box-shadow:
0 0 8px rgba(255, 51, 102, 0.6),
0 0 20px rgba(255, 51, 102, 0.3);
}
/* 渐变背景 */
.status-warning {
background: #ffd000;
box-shadow:
0 0 8px rgba(255, 208, 0, 0.6),
0 0 20px rgba(255, 208, 0, 0.3);
}
/* ========================================
背景效果
======================================== */
.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%);
background:
radial-gradient(ellipse at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(168, 85, 247, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 0% 50%, rgba(255, 45, 149, 0.04) 0%, transparent 40%),
linear-gradient(180deg, #050810 0%, #0a0f18 50%, #050810 100%);
}
/* 标签页效果 */
/* 网格背景 */
.cyber-grid {
background-image:
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
/* 六边形图案 */
.hex-pattern {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5L55 20v20L30 55 5 40V20z' fill='none' stroke='rgba(0,212,255,0.04)' stroke-width='1'/%3E%3C/svg%3E");
background-size: 60px 60px;
}
/* ========================================
标签页效果
======================================== */
.tab-active {
background: linear-gradient(180deg, rgba(88, 166, 255, 0.2) 0%, transparent 100%);
border-bottom: 2px solid #58a6ff;
background: linear-gradient(180deg, rgba(0, 212, 255, 0.15) 0%, transparent 100%);
border-bottom: 2px solid #00d4ff;
box-shadow: 0 2px 10px rgba(0, 212, 255, 0.2);
}
.tab-cyber {
position: relative;
transition: all 0.2s ease;
}
.tab-cyber::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: #00d4ff;
transition: all 0.3s ease;
transform: translateX(-50%);
}
.tab-cyber:hover::after,
.tab-cyber.active::after {
width: 100%;
}
/* ========================================
弹窗和提示
======================================== */
.modal-cyber {
background: linear-gradient(135deg, rgba(10, 15, 24, 0.98) 0%, rgba(5, 8, 16, 0.99) 100%);
border: 1px solid rgba(26, 35, 50, 0.8);
box-shadow:
0 25px 80px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(0, 212, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
/* 命令提示框 */
.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);
background: rgba(10, 15, 24, 0.98);
backdrop-filter: blur(20px);
border: 1px solid rgba(26, 35, 50, 0.8);
box-shadow:
0 15px 50px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
/* 代码高亮 */
/* ========================================
代码高亮
======================================== */
.code-highlight {
color: #58a6ff;
background: rgba(88, 166, 255, 0.1);
padding: 2px 6px;
color: #00d4ff;
background: rgba(0, 212, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
border: 1px solid rgba(0, 212, 255, 0.2);
}
/* ========================================
Logo 和品牌
======================================== */
.logo-cyber {
background: linear-gradient(135deg, #00d4ff 0%, #a855f7 50%, #ff2d95 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 0 10px rgba(0, 212, 255, 0.5));
}
.logo-icon {
background: linear-gradient(135deg, #00d4ff 0%, #a855f7 100%);
box-shadow:
0 0 15px rgba(0, 212, 255, 0.4),
0 0 30px rgba(168, 85, 247, 0.2);
}
/* ========================================
分隔线
======================================== */
.divider-cyber {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.3), transparent);
}
.divider-vertical {
width: 1px;
background: linear-gradient(180deg, transparent, rgba(0, 212, 255, 0.3), transparent);
}
/* ========================================
角落装饰
======================================== */
.corner-decoration {
position: relative;
}
.corner-decoration::before,
.corner-decoration::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.corner-decoration::before {
top: -1px;
left: -1px;
border-right: none;
border-bottom: none;
}
.corner-decoration::after {
bottom: -1px;
right: -1px;
border-left: none;
border-top: none;
}
/* ========================================
选择框样式
======================================== */
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
background: rgba(5, 8, 16, 0.8);
border: 1px solid rgba(26, 35, 50, 0.8);
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
input[type="checkbox"]:checked {
background: rgba(0, 212, 255, 0.2);
border-color: rgba(0, 212, 255, 0.6);
}
input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #00d4ff;
font-size: 12px;
font-weight: bold;
}
input[type="checkbox"]:hover {
border-color: rgba(0, 212, 255, 0.4);
}
/* ========================================
工具提示
======================================== */
.tooltip-cyber {
background: rgba(5, 8, 16, 0.95);
border: 1px solid rgba(0, 212, 255, 0.3);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
color: #e8f0ff;
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
}
/* ========================================
加载动画
======================================== */
.loader-cyber {
width: 40px;
height: 40px;
border: 3px solid rgba(26, 35, 50, 0.5);
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 打字光标动画 */
.typing-cursor::after {
content: '▋';
animation: typing 1s steps(1) infinite;
color: #00d4ff;
}
@keyframes typing {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ========================================
进度条
======================================== */
.progress-cyber {
background: rgba(26, 35, 50, 0.5);
border-radius: 10px;
overflow: hidden;
height: 6px;
}
.progress-cyber-bar {
background: linear-gradient(90deg, #00d4ff, #a855f7);
height: 100%;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
transition: width 0.3s ease;
}
/* ========================================
徽章
======================================== */
.badge-cyber {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.15) 0%, rgba(168, 85, 247, 0.1) 100%);
border: 1px solid rgba(0, 212, 255, 0.3);
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* ========================================
移动端适配
======================================== */
@media (max-width: 768px) {
body {
font-size: 14px;
}
.terminal-container {
border-radius: 0;
}
.xterm {
padding: 8px;
}
}
/* 安全区域适配 (iPhone X+) */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-right {
padding-right: env(safe-area-inset-right);
}
/* 触摸优化 */
@media (pointer: coarse) {
button, .btn-cyber, .card-hover {
min-height: 44px;
min-width: 44px;
}
input, textarea, select {
font-size: 16px; /* 防止 iOS 自动缩放 */
}
}
/* 隐藏移动端滚动条 */
@media (max-width: 768px) {
::-webkit-scrollbar {
display: none;
}
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
}

398
src/services/api.js Normal file
View File

@ -0,0 +1,398 @@
/**
* EasyShell - 跨平台 API 适配层
* 自动检测环境并使用对应的通信方式
* - Electron 环境: 使用 IPC 直连
* - Web/Mobile 环境: 使用 WebSocket 连接服务器
*/
import { io } from 'socket.io-client';
// 检测是否在 Electron 环境中
const isElectron = () => {
return typeof window !== 'undefined' && window.electronAPI !== undefined;
};
// 检测是否是 Capacitor 环境
const isCapacitor = () => {
return typeof window !== 'undefined' && window.Capacitor !== undefined;
};
// 服务器地址配置
const getServerUrl = () => {
// 可以从本地存储读取配置的服务器地址
const savedUrl = localStorage.getItem('easyshell_server_url');
if (savedUrl) return savedUrl;
// 默认地址
if (process.env.NODE_ENV === 'development') {
return 'http://localhost:3001';
}
// 生产环境需要配置实际的服务器地址
return localStorage.getItem('easyshell_server_url') || 'http://localhost:3001';
};
// Socket.IO 客户端实例
let socket = null;
let connectionListeners = new Map();
// 初始化 WebSocket 连接
const initSocket = () => {
if (socket?.connected) return socket;
const serverUrl = getServerUrl();
console.log(`🔌 连接服务器: ${serverUrl}`);
socket = io(serverUrl, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
console.log('✅ 服务器连接成功');
});
socket.on('disconnect', () => {
console.log('📤 服务器连接断开');
});
socket.on('connect_error', (error) => {
console.error('❌ 服务器连接错误:', error.message);
});
return socket;
};
// 确保 Socket 连接
const ensureSocket = () => {
if (!socket || !socket.connected) {
initSocket();
}
return socket;
};
// ========== WebSocket API 实现 ==========
const webSocketAPI = {
// SSH 操作
ssh: {
connect: (hostConfig) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('ssh:connect', hostConfig, resolve);
});
},
write: (connectionId, data) => {
const sock = ensureSocket();
sock.emit('ssh:write', { connectionId, data });
},
resize: (connectionId, cols, rows) => {
const sock = ensureSocket();
sock.emit('ssh:resize', { connectionId, cols, rows });
},
disconnect: (connectionId) => {
const sock = ensureSocket();
sock.emit('ssh:disconnect', connectionId);
},
exec: (hostConfig, command) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('ssh:exec', { hostConfig, command }, resolve);
});
},
test: async (hostConfig) => {
try {
const result = await webSocketAPI.ssh.exec(hostConfig, 'echo "connected"');
return { success: result.success, message: result.success ? '连接成功' : result.error };
} catch (error) {
return { success: false, message: error.message };
}
},
onData: (connectionId, callback) => {
const sock = ensureSocket();
const channel = `ssh:data:${connectionId}`;
sock.on(channel, callback);
return () => sock.off(channel, callback);
},
onClose: (connectionId, callback) => {
const sock = ensureSocket();
const channel = `ssh:close:${connectionId}`;
sock.on(channel, callback);
return () => sock.off(channel, callback);
},
onError: (connectionId, callback) => {
const sock = ensureSocket();
const channel = `ssh:error:${connectionId}`;
sock.on(channel, callback);
return () => sock.off(channel, callback);
},
},
// SFTP 操作
sftp: {
list: (hostConfig, remotePath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:list', { hostConfig, remotePath }, resolve);
});
},
mkdir: (hostConfig, remotePath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:mkdir', { hostConfig, remotePath }, resolve);
});
},
delete: (hostConfig, remotePath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:delete', { hostConfig, remotePath }, resolve);
});
},
rmdir: (hostConfig, remotePath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:rmdir', { hostConfig, remotePath }, resolve);
});
},
rename: (hostConfig, oldPath, newPath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:rename', { hostConfig, oldPath, newPath }, resolve);
});
},
readFile: (hostConfig, remotePath) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:readFile', { hostConfig, remotePath }, resolve);
});
},
writeFile: (hostConfig, remotePath, content) => {
return new Promise((resolve) => {
const sock = ensureSocket();
sock.emit('sftp:writeFile', { hostConfig, remotePath, content }, resolve);
});
},
// 移动端暂不支持文件下载/上传进度
download: async (hostConfig, remotePath) => {
// 移动端通过读取文件内容来"下载"
const result = await webSocketAPI.sftp.readFile(hostConfig, remotePath);
if (result.success) {
// 创建 Blob 并触发下载
const blob = new Blob([result.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = remotePath.split('/').pop();
a.click();
URL.revokeObjectURL(url);
return { success: true };
}
return result;
},
upload: async (hostConfig, localPath, remotePath) => {
// 移动端需要通过文件选择器获取内容
return { success: false, error: '请使用文件选择器上传' };
},
onProgress: (callback) => {
// WebSocket 模式暂不支持进度回调
return () => {};
},
},
// 主机管理 - 使用本地存储
hosts: {
getAll: () => {
const hosts = localStorage.getItem('easyshell_hosts');
return hosts ? JSON.parse(hosts) : [];
},
getById: (id) => {
const hosts = webSocketAPI.hosts.getAll();
return hosts.find(h => h.id === id);
},
add: (host) => {
const hosts = webSocketAPI.hosts.getAll();
const newHost = {
...host,
id: Date.now(),
created_at: Date.now(),
updated_at: Date.now(),
};
hosts.push(newHost);
localStorage.setItem('easyshell_hosts', JSON.stringify(hosts));
return newHost;
},
update: (id, data) => {
const hosts = webSocketAPI.hosts.getAll();
const index = hosts.findIndex(h => h.id === id);
if (index !== -1) {
hosts[index] = { ...hosts[index], ...data, updated_at: Date.now() };
localStorage.setItem('easyshell_hosts', JSON.stringify(hosts));
return hosts[index];
}
return null;
},
delete: (id) => {
const hosts = webSocketAPI.hosts.getAll();
const filtered = hosts.filter(h => h.id !== id);
localStorage.setItem('easyshell_hosts', JSON.stringify(filtered));
return { success: true };
},
},
// 命令管理 - 使用本地存储
commands: {
getAll: () => {
const commands = localStorage.getItem('easyshell_commands');
return commands ? JSON.parse(commands) : [];
},
search: (keyword) => {
const commands = webSocketAPI.commands.getAll();
if (!keyword) return commands;
return commands.filter(c =>
c.command.includes(keyword) || c.description?.includes(keyword)
);
},
add: (command) => {
const commands = webSocketAPI.commands.getAll();
const existing = commands.find(c => c.command === command.command);
if (existing) {
existing.usage_count = (existing.usage_count || 0) + 1;
} else {
commands.push({ ...command, id: Date.now(), usage_count: 1 });
}
localStorage.setItem('easyshell_commands', JSON.stringify(commands));
return command;
},
incrementUsage: (id) => {
const commands = webSocketAPI.commands.getAll();
const cmd = commands.find(c => c.id === id);
if (cmd) {
cmd.usage_count = (cmd.usage_count || 0) + 1;
localStorage.setItem('easyshell_commands', JSON.stringify(commands));
}
},
},
// 代码片段 - 使用本地存储
snippets: {
getAll: () => {
const snippets = localStorage.getItem('easyshell_snippets');
return snippets ? JSON.parse(snippets) : [];
},
add: (snippet) => {
const snippets = webSocketAPI.snippets.getAll();
snippets.push({ ...snippet, id: Date.now() });
localStorage.setItem('easyshell_snippets', JSON.stringify(snippets));
return snippet;
},
delete: (id) => {
const snippets = webSocketAPI.snippets.getAll();
const filtered = snippets.filter(s => s.id !== id);
localStorage.setItem('easyshell_snippets', JSON.stringify(filtered));
return { success: true };
},
},
// 数据库同步 - WebSocket 模式使用本地存储
db: {
saveConfig: (config) => {
localStorage.setItem('easyshell_db_config', JSON.stringify(config));
return { success: true };
},
getConfig: () => {
const config = localStorage.getItem('easyshell_db_config');
return config ? JSON.parse(config) : null;
},
isRemoteConnected: () => false,
connectMySQL: async () => ({ success: false, error: '移动端暂不支持 MySQL 同步' }),
disconnectMySQL: async () => ({ success: true }),
syncToRemote: async () => ({ success: false }),
syncFromRemote: async () => ({ success: false }),
smartSync: async () => ({ success: false }),
},
// 窗口控制 - 移动端不需要
window: {
minimize: () => {},
maximize: () => {},
close: () => {},
isMaximized: () => false,
},
};
// ========== 导出统一 API ==========
// 根据环境选择 API 实现
export const getAPI = () => {
if (isElectron()) {
console.log('📱 使用 Electron API');
return window.electronAPI;
} else {
console.log('🌐 使用 WebSocket API');
return webSocketAPI;
}
};
// 服务器配置
export const serverConfig = {
getUrl: getServerUrl,
setUrl: (url) => {
localStorage.setItem('easyshell_server_url', url);
// 重新连接
if (socket) {
socket.disconnect();
socket = null;
}
},
isConnected: () => socket?.connected || false,
reconnect: () => {
if (socket) {
socket.disconnect();
socket = null;
}
initSocket();
},
};
// 平台检测
export const platform = {
isElectron,
isCapacitor,
isMobile: () => isCapacitor() || /Android|iPhone|iPad|iPod/i.test(navigator.userAgent),
isDesktop: () => isElectron() || (!isCapacitor() && !/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)),
};
export default getAPI;

View File

@ -542,9 +542,25 @@ class DatabaseService {
return { success: true };
}
deleteHost(id) {
async deleteHost(id) {
// 先获取主机信息,用于远程删除
const host = this.runQuerySingle('SELECT host FROM hosts WHERE id = ?', [id]);
// 删除本地记录
this.sqliteDb.run('DELETE FROM hosts WHERE id = ?', [id]);
this.saveDatabase();
// 如果连接了远程 MySQL同步删除远程记录
if (this.isRemoteConnected && this.mysqlConnection && host) {
try {
await this.mysqlConnection.execute('DELETE FROM hosts WHERE host = ?', [host.host]);
console.log(`✅ 远程数据库同步删除主机: ${host.host}`);
} catch (err) {
console.error('⚠️ 远程数据库删除失败:', err.message);
// 不影响本地删除的结果
}
}
return { success: true };
}

497
src/services/sftp.js Normal file
View File

@ -0,0 +1,497 @@
/**
* SFTP文件传输服务
*/
const { Client } = require('ssh2');
const path = require('path');
const fs = require('fs');
const { dialog, app } = require('electron');
class SFTPService {
constructor() {
this.progressCallback = null;
}
/**
* 设置进度回调
*/
setProgressCallback(callback) {
this.progressCallback = callback;
}
/**
* 创建SFTP连接
*/
createConnection(hostConfig) {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) {
conn.end();
reject(err);
return;
}
resolve({ conn, sftp });
});
});
conn.on('error', (err) => {
reject(err);
});
const connectConfig = {
host: hostConfig.host,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 20000,
};
if (hostConfig.privateKey && hostConfig.privateKey.trim()) {
connectConfig.privateKey = hostConfig.privateKey;
}
if (hostConfig.password && hostConfig.password.trim()) {
connectConfig.password = hostConfig.password;
}
conn.connect(connectConfig);
});
}
/**
* 列出目录内容
*/
async list(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
conn.end();
if (err) {
reject(err);
return;
}
// 格式化文件列表
const files = list.map(item => ({
filename: item.filename,
longname: item.longname,
attrs: {
size: item.attrs.size,
mtime: item.attrs.mtime,
atime: item.attrs.atime,
uid: item.attrs.uid,
gid: item.attrs.gid,
mode: item.attrs.mode,
isDirectory: (item.attrs.mode & 0o40000) === 0o40000,
isFile: (item.attrs.mode & 0o100000) === 0o100000,
isSymbolicLink: (item.attrs.mode & 0o120000) === 0o120000,
}
}));
resolve({ success: true, files });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 下载文件
*/
async download(hostConfig, remotePath, mainWindow) {
// 选择保存位置
const result = await dialog.showSaveDialog(mainWindow, {
title: '保存文件',
defaultPath: path.basename(remotePath),
properties: ['createDirectory', 'showOverwriteConfirmation'],
});
if (result.canceled || !result.filePath) {
return { success: false, error: '用户取消' };
}
const localPath = result.filePath;
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
// 获取文件大小
sftp.stat(remotePath, (err, stats) => {
if (err) {
conn.end();
reject(err);
return;
}
const totalSize = stats.size;
let downloadedSize = 0;
const filename = path.basename(remotePath);
// 创建读写流
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
readStream.on('data', (chunk) => {
downloadedSize += chunk.length;
const percent = Math.round((downloadedSize / totalSize) * 100);
if (this.progressCallback) {
this.progressCallback({
type: 'download',
filename,
percent,
transferred: downloadedSize,
total: totalSize,
});
}
});
readStream.on('error', (err) => {
conn.end();
fs.unlink(localPath, () => {});
reject(err);
});
writeStream.on('error', (err) => {
conn.end();
reject(err);
});
writeStream.on('close', () => {
conn.end();
resolve({ success: true, localPath });
});
readStream.pipe(writeStream);
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 上传文件
*/
async upload(hostConfig, localPath, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
const stats = fs.statSync(localPath);
const totalSize = stats.size;
let uploadedSize = 0;
const filename = path.basename(localPath);
// 创建读写流
const readStream = fs.createReadStream(localPath);
const writeStream = sftp.createWriteStream(remotePath);
readStream.on('data', (chunk) => {
uploadedSize += chunk.length;
const percent = Math.round((uploadedSize / totalSize) * 100);
if (this.progressCallback) {
this.progressCallback({
type: 'upload',
filename,
percent,
transferred: uploadedSize,
total: totalSize,
});
}
});
readStream.on('error', (err) => {
conn.end();
reject(err);
});
writeStream.on('error', (err) => {
conn.end();
reject(err);
});
writeStream.on('close', () => {
conn.end();
resolve({ success: true, remotePath });
});
readStream.pipe(writeStream);
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 删除文件
*/
async delete(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 创建目录
*/
async mkdir(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 删除目录(递归)
*/
async rmdir(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
// 递归删除目录内容
const deleteRecursive = async (dirPath) => {
return new Promise((resolve, reject) => {
sftp.readdir(dirPath, async (err, list) => {
if (err) {
reject(err);
return;
}
try {
for (const item of list) {
const itemPath = `${dirPath}/${item.filename}`;
const isDir = (item.attrs.mode & 0o40000) === 0o40000;
if (isDir) {
await deleteRecursive(itemPath);
} else {
await new Promise((res, rej) => {
sftp.unlink(itemPath, (err) => {
if (err) rej(err);
else res();
});
});
}
}
// 删除空目录
sftp.rmdir(dirPath, (err) => {
if (err) reject(err);
else resolve();
});
} catch (e) {
reject(e);
}
});
});
};
await deleteRecursive(remotePath);
conn.end();
return { success: true };
} catch (err) {
if (conn) conn.end();
return { success: false, error: err.message };
}
}
/**
* 重命名文件/目录
*/
async rename(hostConfig, oldPath, newPath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.rename(oldPath, newPath, (err) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 写入文件内容
*/
async writeFile(hostConfig, remotePath, content) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
const writeStream = sftp.createWriteStream(remotePath);
writeStream.on('error', (err) => {
conn.end();
reject(err);
});
writeStream.on('close', () => {
conn.end();
resolve({ success: true });
});
writeStream.end(content);
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 读取文件内容
*/
async readFile(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
let content = '';
const readStream = sftp.createReadStream(remotePath);
readStream.on('data', (chunk) => {
content += chunk.toString();
});
readStream.on('error', (err) => {
conn.end();
reject(err);
});
readStream.on('end', () => {
conn.end();
resolve({ success: true, content });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 获取文件状态
*/
async stat(hostConfig, remotePath) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({
success: true,
stats: {
size: stats.size,
mtime: stats.mtime,
atime: stats.atime,
mode: stats.mode,
isDirectory: (stats.mode & 0o40000) === 0o40000,
isFile: (stats.mode & 0o100000) === 0o100000,
}
});
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 修改文件权限
*/
async chmod(hostConfig, remotePath, mode) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.chmod(remotePath, mode, (err) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* 修改文件所有者
*/
async chown(hostConfig, remotePath, uid, gid) {
let conn, sftp;
try {
({ conn, sftp } = await this.createConnection(hostConfig));
return new Promise((resolve, reject) => {
sftp.chown(remotePath, uid, gid, (err) => {
conn.end();
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
} catch (err) {
return { success: false, error: err.message };
}
}
}
module.exports = new SFTPService();

View File

@ -8,42 +8,72 @@ module.exports = {
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',
// 深邃的太空黑背景
'bg': '#050810',
'surface': '#0a0f18',
'card': '#0f1520',
'border': '#1a2332',
'border-light': '#253244',
// 霓虹主色调 - 电光蓝
'accent': '#00d4ff',
'accent-glow': '#00a8cc',
'accent-dim': '#007a99',
// 霓虹辅助色
'neon-pink': '#ff2d95',
'neon-purple': '#a855f7',
'neon-green': '#00ff88',
'neon-yellow': '#ffd000',
'neon-orange': '#ff6b35',
// 状态色
'success': '#00ff88',
'warning': '#ffd000',
'error': '#ff3366',
// 文字
'text': '#e8f0ff',
'text-dim': '#6b7a94',
'text-muted': '#3d4a5c',
// 特殊效果色
'cyan': '#00d4ff',
'purple': '#a855f7',
'pink': '#ff2d95',
'orange': '#ff6b35',
}
},
fontFamily: {
'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'monospace'],
'display': ['Space Grotesk', 'SF Pro Display', 'system-ui', 'sans-serif'],
'display': ['Rajdhani', 'Orbitron', 'system-ui', 'sans-serif'],
'body': ['Inter', '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)',
'glow': '0 0 20px rgba(0, 212, 255, 0.3)',
'glow-lg': '0 0 40px rgba(0, 212, 255, 0.4)',
'glow-pink': '0 0 30px rgba(255, 45, 149, 0.3)',
'glow-green': '0 0 20px rgba(0, 255, 136, 0.3)',
'card': '0 8px 32px rgba(0, 0, 0, 0.6)',
'neon': '0 0 5px currentColor, 0 0 20px currentColor',
'inner-glow': 'inset 0 0 20px rgba(0, 212, 255, 0.1)',
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
'slide-up': 'slideUp 0.3s ease-out',
'slide-in': 'slideIn 0.2s ease-out',
'fade-in': 'fadeIn 0.2s ease-out',
'scan': 'scan 3s linear infinite',
'float': 'float 6s ease-in-out infinite',
'border-flow': 'borderFlow 3s linear infinite',
'shimmer': 'shimmer 2s linear infinite',
'typing': 'typing 1s steps(3) infinite',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px rgba(88, 166, 255, 0.2)' },
'100%': { boxShadow: '0 0 20px rgba(88, 166, 255, 0.6)' },
'0%': { boxShadow: '0 0 5px rgba(0, 212, 255, 0.2)' },
'100%': { boxShadow: '0 0 25px rgba(0, 212, 255, 0.6)' },
},
glowPulse: {
'0%, 100%': { opacity: '0.5', filter: 'brightness(1)' },
'50%': { opacity: '1', filter: 'brightness(1.2)' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
@ -57,12 +87,36 @@ module.exports = {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
scan: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
borderFlow: {
'0%': { backgroundPosition: '0% 50%' },
'100%': { backgroundPosition: '200% 50%' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
typing: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0' },
},
},
backdropBlur: {
'xs': '2px',
}
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'cyber-grid': 'linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px)',
'hex-pattern': 'url("data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' viewBox=\'0 0 60 60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M30 0L60 15v30L30 60 0 45V15z\' fill=\'none\' stroke=\'rgba(0,212,255,0.05)\' stroke-width=\'1\'/%3E%3C/svg%3E")',
},
},
},
plugins: [],
}