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:
parent
b7f6e9fcf6
commit
c0fe5b3321
318
README.md
318
README.md
@ -1,178 +1,179 @@
|
||||
# 🚀 EasyShell
|
||||
# EasyShell 🚀
|
||||
|
||||
高颜值远程 Shell 管理终端 - 一款现代化的 SSH 连接管理工具
|
||||
> 赛博朋克风格跨平台远程 Shell 管理终端
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
支持 **Windows / macOS / Linux / Android** 多平台运行。
|
||||
|
||||
## ✨ 特性
|
||||
  
|
||||
|
||||
- 🎨 **高颜值界面** - 现代化深色主题,精心设计的 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 Tools(Windows)/ Xcode(macOS)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-username/easyshell.git
|
||||
cd easyshell
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 重新编译原生模块(如果遇到问题)
|
||||
npm rebuild better-sqlite3 --build-from-source
|
||||
npm rebuild ssh2 --build-from-source
|
||||
|
||||
# 启动开发模式
|
||||
npm start
|
||||
```
|
||||
|
||||
### 构建发布版本
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run dist
|
||||
```
|
||||
|
||||
## 🚀 使用说明
|
||||
|
||||
### 本地模式
|
||||
|
||||
应用默认使用本地 SQLite 数据库存储数据,无需任何配置即可使用。
|
||||
|
||||
### 远程同步模式
|
||||
|
||||
1. 点击左下角「本地模式」或设置图标
|
||||
2. 输入 MySQL 服务器信息:
|
||||
- 主机地址
|
||||
- 端口(默认 3306)
|
||||
- 用户名
|
||||
- 密码
|
||||
- 数据库名(默认 easyshell)
|
||||
3. 点击「连接数据库」
|
||||
4. 系统将自动创建数据库和所需的表结构
|
||||
|
||||
### 添加主机
|
||||
|
||||
1. 点击侧边栏的 ➕ 按钮或「添加主机」
|
||||
2. 填写主机信息:
|
||||
- 名称(显示名)
|
||||
- 分组(用于分类)
|
||||
- 主机地址
|
||||
- 端口(默认 22)
|
||||
- 用户名
|
||||
- 密码或 SSH 私钥
|
||||
3. 可选择标识颜色
|
||||
4. 点击「测试连接」验证配置
|
||||
5. 点击「添加主机」保存
|
||||
|
||||
### 命令提示
|
||||
|
||||
- 按 `Ctrl+K` 打开命令面板
|
||||
- 搜索或浏览预设命令
|
||||
- 使用方向键选择,回车执行
|
||||
- 命令会直接发送到当前终端
|
||||
|
||||
## 📁 项目结构
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
easyshell/
|
||||
├── 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 # 预加载脚本(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 服务
|
||||
├── 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
39
capacitor.config.ts
Normal 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
62
main.js
@ -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
2183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
23
preload.js
23
preload.js
@ -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
BIN
public/icon-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
public/icon.ico
Normal file
BIN
public/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
47
public/icon.svg
Normal file
47
public/icon.svg
Normal 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 |
@ -1,43 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
|
||||
<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管理终端" />
|
||||
<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=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<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: #0a0e14;
|
||||
background: #050810;
|
||||
}
|
||||
|
||||
/* 全局滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
background: rgba(10, 15, 24, 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
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: #484f58;
|
||||
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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
<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
108
public/logo.svg
Normal 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
80
scripts/generate-icons.js
Normal 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
471
server/index.js
Normal 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
20
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
282
src/App.js
282
src/App.js
@ -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();
|
||||
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,95 +147,197 @@ 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">
|
||||
<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">
|
||||
<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
|
||||
<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"
|
||||
>
|
||||
<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-6">
|
||||
高颜值远程 Shell 管理终端
|
||||
<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">
|
||||
<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 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"
|
||||
>
|
||||
添加主机
|
||||
</button>
|
||||
<button
|
||||
+ 添加主机
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={openSettings}
|
||||
className="px-6 py-3 bg-shell-card border border-shell-border
|
||||
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-accent/30 transition-all font-medium"
|
||||
hover:border-shell-neon-purple/30 hover:bg-shell-neon-purple/10
|
||||
transition-all font-display tracking-wide text-sm"
|
||||
>
|
||||
连接数据库
|
||||
</button>
|
||||
⚡ 云端同步
|
||||
</motion.button>
|
||||
</div>
|
||||
<p className="text-shell-text-dim text-sm mt-8">
|
||||
按 <kbd className="code-highlight">Ctrl+K</kbd> 打开命令面板
|
||||
</p>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<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"
|
||||
>
|
||||
<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) => (
|
||||
@ -228,11 +351,49 @@ function App() {
|
||||
hostId={tab.hostId}
|
||||
onConnectionChange={(connected) => handleConnectionChange(tab.id, connected)}
|
||||
onShowCommandPalette={openCommandPalette}
|
||||
onToggleInfoPanel={() => setShowInfoPanel(!showInfoPanel)}
|
||||
onOpenSFTP={() => setShowSFTP(true)}
|
||||
showInfoPanel={showInfoPanel}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
411
src/components/HostEditPanel.js
Normal file
411
src/components/HostEditPanel.js
Normal 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;
|
||||
|
||||
388
src/components/HostInfoPanel.js
Normal file
388
src/components/HostInfoPanel.js
Normal 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;
|
||||
|
||||
@ -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,11 +177,21 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{hosts.map((host) => (
|
||||
{hosts.map((host) => {
|
||||
const isSelected = editingHost?.id === host.id;
|
||||
return (
|
||||
<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"
|
||||
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
|
||||
@ -194,34 +210,34 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-shell-border">
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-shell-border/50">
|
||||
<button
|
||||
onClick={() => onConnect(host)}
|
||||
className="flex-1 px-3 py-1.5 bg-shell-accent/20 text-shell-accent text-sm
|
||||
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={() => {
|
||||
setEditingHost(host);
|
||||
setIsEditing(true);
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id);
|
||||
}}
|
||||
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"
|
||||
title="删除主机"
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{hosts.length === 0 && (
|
||||
<div className="text-center py-8 text-shell-text-dim">
|
||||
|
||||
1321
src/components/SFTPBrowser.js
Normal file
1321
src/components/SFTPBrowser.js
Normal file
File diff suppressed because it is too large
Load Diff
207
src/components/ServerConfig.js
Normal file
207
src/components/ServerConfig.js
Normal 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;
|
||||
|
||||
@ -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,190 +39,315 @@ 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">
|
||||
<AnimatePresence>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm font-semibold text-shell-text"
|
||||
<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">
|
||||
主机列表
|
||||
</motion.span>
|
||||
</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">
|
||||
<AnimatePresence>
|
||||
{!collapsed && (
|
||||
<div className="px-2 py-1 text-xs font-medium text-shell-text-dim uppercase tracking-wider">
|
||||
<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}
|
||||
</div>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-shell-border to-transparent" />
|
||||
</motion.div>
|
||||
)}
|
||||
{groupHosts.map((host) => {
|
||||
</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)}
|
||||
{/* 选中指示线 */}
|
||||
{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`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${host.color}20` }}
|
||||
>
|
||||
<FiServer size={16} style={{ color: host.color }} />
|
||||
</div>
|
||||
<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 && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">
|
||||
<div className="text-xs text-shell-text-dim truncate font-mono">
|
||||
{host.username}@{host.host}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
{/* 快速连接按钮 */}
|
||||
<AnimatePresence>
|
||||
{!collapsed && (
|
||||
<button
|
||||
<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();
|
||||
onEditHost && onEditHost(host);
|
||||
onConnectHost && onConnectHost(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
|
||||
bg-shell-accent/20 border border-shell-accent/30
|
||||
text-shell-accent hover:bg-shell-accent/30
|
||||
transition-all flex-shrink-0"
|
||||
title="编辑主机"
|
||||
title="快速连接"
|
||||
>
|
||||
<FiEdit2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!collapsed && isActive && (
|
||||
<div className="w-2 h-2 rounded-full status-online flex-shrink-0" />
|
||||
<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
|
||||
onClick={onOpenHostManager}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim"
|
||||
title="添加主机"
|
||||
{/* 主机管理入口 */}
|
||||
{hosts.length > 0 && !collapsed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="px-3 py-2 mt-2"
|
||||
>
|
||||
<FiTerminal size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onOpenHostManager}
|
||||
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"
|
||||
>
|
||||
<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} />}
|
||||
{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">
|
||||
{isRemoteConnected ? '已同步' : '本地模式'}
|
||||
<span className="text-xs font-medium">云端同步</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiCloudOff size={16} />
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-medium">本地模式</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
<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>
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors"
|
||||
title="设置"
|
||||
>
|
||||
<FiSettings size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 折叠状态下的设置按钮 */}
|
||||
{collapsed && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim
|
||||
hover:text-shell-text transition-colors w-full flex justify-center"
|
||||
title="设置"
|
||||
>
|
||||
<FiSettings size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<span className="text-sm font-semibold text-shell-text">
|
||||
EasyShell
|
||||
<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="text-xs text-shell-text-dim px-2 py-0.5 bg-shell-card rounded-full">
|
||||
v1.0.0
|
||||
{/* 版本徽章 */}
|
||||
<span className="badge-cyber text-shell-accent">
|
||||
v1.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态指示器 */}
|
||||
<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;
|
||||
|
||||
|
||||
688
src/index.css
688
src/index.css
@ -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
398
src/services/api.js
Normal 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;
|
||||
|
||||
@ -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
497
src/services/sftp.js
Normal 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();
|
||||
|
||||
@ -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: [],
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user