First commit
This commit is contained in:
commit
ce1b546a6c
39 files changed
+10654
No files matched your search
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,338 @@
|
||||
# WebSSH - 在线SSH终端工具
|
||||
|
||||
一个基于Web的SSH终端工具,模仿Xshell的功能,提供完整的Web版SSH客户端体验。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### ✅ 已实现功能
|
||||
|
||||
#### 用户认证系统 (P0)
|
||||
- 用户注册(邮箱+密码)
|
||||
- 用户登录(JWT令牌认证)
|
||||
- 密码加密存储(bcrypt)
|
||||
- 路由守卫和权限控制
|
||||
|
||||
#### 服务器资产管理 (P0)
|
||||
- 服务器增删改查
|
||||
- 支持密码和私钥认证
|
||||
- AES-256加密存储敏感信息
|
||||
- 服务器分组管理
|
||||
- 树形结构展示
|
||||
|
||||
#### SSH终端核心功能 (P0)
|
||||
- 基于xterm.js的Web终端
|
||||
- WebSocket实时通信
|
||||
- SSH2库后端连接
|
||||
- 终端窗口自适应
|
||||
- 命令输入和输出
|
||||
- 快捷键支持(Ctrl+C等)
|
||||
|
||||
#### 前端界面 (P0)
|
||||
- Vue3 + Element Plus现代化界面
|
||||
- 响应式设计
|
||||
- 服务器管理面板
|
||||
- 终端界面
|
||||
- 快速连接功能
|
||||
|
||||
### 🔄 待实现功能
|
||||
|
||||
- [ ] SFTP文件传输功能 (P1)
|
||||
- [ ] 多标签页支持 (P1)
|
||||
- [ ] 分屏显示 (P1)
|
||||
- [ ] 终端外观定制 (P2)
|
||||
- [ ] 快捷命令功能 (P2)
|
||||
- [ ] 连接保持和自动重连 (P1)
|
||||
- [ ] 操作日志记录 (P2)
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 前端技术栈
|
||||
- **框架**: Vue 3 + Vite
|
||||
- **UI组件库**: Element Plus
|
||||
- **终端组件**: xterm.js + xterm-addon-fit
|
||||
- **状态管理**: Pinia
|
||||
- **路由**: Vue Router
|
||||
- **HTTP客户端**: Axios
|
||||
- **WebSocket**: socket.io-client
|
||||
|
||||
### 后端技术栈
|
||||
- **运行时**: Node.js
|
||||
- **Web框架**: Express.js
|
||||
- **SSH连接**: ssh2库
|
||||
- **实时通信**: Socket.IO
|
||||
- **数据库**: MySQL
|
||||
- **认证**: JWT + bcryptjs
|
||||
- **加密**: AES-256-GCM
|
||||
|
||||
### 数据库设计
|
||||
```sql
|
||||
-- 用户表
|
||||
users (id, email, phone, password_hash, created_at, updated_at)
|
||||
|
||||
-- 服务器表
|
||||
servers (id, user_id, name, host, port, username, password_encrypted,
|
||||
private_key_encrypted, auth_type, group_name, created_at, updated_at)
|
||||
|
||||
-- 连接日志表
|
||||
connection_logs (id, user_id, server_id, host, start_time, end_time, duration)
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
webssh/
|
||||
├── backend/ # Node.js后端服务
|
||||
│ ├── config/
|
||||
│ │ └── database.js # 数据库配置和初始化
|
||||
│ ├── routes/ # API路由
|
||||
│ │ ├── auth.js # 认证相关路由
|
||||
│ │ └── servers.js # 服务器管理路由
|
||||
│ ├── socket/ # WebSocket处理
|
||||
│ │ └── socketHandler.js # SSH连接处理
|
||||
│ ├── utils/
|
||||
│ │ └── encryption.js # 加密工具类
|
||||
│ ├── .env # 环境变量配置
|
||||
│ ├── package.json # 后端依赖配置
|
||||
│ └── server.js # 主服务器入口
|
||||
├── frontend/ # Vue3前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 可复用组件
|
||||
│ │ │ └── ServerForm.vue # 服务器表单组件
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ │ └── index.js # 路由定义和守卫
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ │ ├── auth.js # 用户认证状态
|
||||
│ │ │ ├── servers.js # 服务器管理状态
|
||||
│ │ │ └── terminal.js # 终端连接状态
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ │ ├── Dashboard.vue # 主仪表板
|
||||
│ │ │ ├── Login.vue # 登录页面
|
||||
│ │ │ ├── Register.vue # 注册页面
|
||||
│ │ │ └── Terminal.vue # 终端页面
|
||||
│ │ ├── App.vue # 根组件
|
||||
│ │ └── main.js # 应用入口
|
||||
│ ├── index.html # HTML模板
|
||||
│ ├── package.json # 前端依赖配置
|
||||
│ └── vite.config.js # Vite构建配置
|
||||
├── package.json # 根项目配置
|
||||
└── README.md # 项目说明文档
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 16+
|
||||
- MySQL 5.7+
|
||||
|
||||
### 1. 克隆项目
|
||||
```bash
|
||||
git clone <项目地址>
|
||||
cd webssh
|
||||
```
|
||||
|
||||
### 2. 数据库设置
|
||||
```sql
|
||||
-- 创建数据库
|
||||
CREATE DATABASE webssh CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建用户(可选)
|
||||
CREATE USER 'webssh'@'localhost' IDENTIFIED BY 'webssh';
|
||||
GRANT ALL PRIVILEGES ON webssh.* TO 'webssh'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
```bash
|
||||
# 安装后端依赖
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 安装前端依赖
|
||||
cd ../frontend
|
||||
npm install
|
||||
|
||||
# 返回根目录安装开发依赖
|
||||
cd ..
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. 环境配置
|
||||
```bash
|
||||
# 复制环境配置文件
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
编辑 `backend/.env` 文件:
|
||||
```env
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=webssh
|
||||
DB_PASSWORD=webssh
|
||||
DB_NAME=webssh
|
||||
|
||||
# JWT Secret (生成随机密钥)
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Encryption Key (必须32字节长)
|
||||
ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
- `ENCRYPTION_KEY` 必须是正好32字节的字符串
|
||||
- 生产环境请使用强密码生成器创建安全的密钥
|
||||
- 不要将真实的密钥提交到版本控制系统
|
||||
|
||||
### 5. 启动应用
|
||||
|
||||
#### 开发模式(推荐)
|
||||
```bash
|
||||
# 同时启动前后端(需要concurrently)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### 分别启动
|
||||
```bash
|
||||
# 启动后端服务(端口3000)
|
||||
npm run dev:backend
|
||||
|
||||
# 启动前端服务(端口5173)
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
#### 生产构建
|
||||
```bash
|
||||
# 构建前端
|
||||
npm run build
|
||||
|
||||
# 启动生产服务器
|
||||
npm start
|
||||
```
|
||||
|
||||
### 6. 访问应用
|
||||
- 前端地址:http://localhost:5173
|
||||
- 后端API:http://localhost:3000
|
||||
|
||||
## 🔧 使用指南
|
||||
|
||||
### 首次使用
|
||||
1. 访问 http://localhost:5173/register 注册账号
|
||||
2. 登录后进入仪表板
|
||||
3. 添加你的第一个SSH服务器
|
||||
4. 点击服务器名称开始连接
|
||||
|
||||
### 服务器配置
|
||||
- **名称**: 服务器显示名称
|
||||
- **主机**: IP地址或域名
|
||||
- **端口**: SSH端口(默认22)
|
||||
- **用户名**: SSH登录用户名
|
||||
- **认证方式**: 密码认证或私钥认证
|
||||
- **分组**: 服务器分组(可选)
|
||||
|
||||
### 终端操作
|
||||
- **连接**: 点击"连接"按钮建立SSH会话
|
||||
- **命令输入**: 在底部输入框输入命令
|
||||
- **快捷键**:
|
||||
- `Ctrl+C`: 中断当前命令
|
||||
- `Ctrl+D`: 退出会话
|
||||
- `Ctrl+L`: 清屏
|
||||
- **断开**: 点击"断开"按钮结束连接
|
||||
|
||||
### 快速连接
|
||||
- 不保存服务器信息,直接输入连接参数
|
||||
- 适合临时连接或测试使用
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
### 数据加密
|
||||
- **用户密码**: bcrypt加密存储
|
||||
- **服务器认证信息**: AES-256-GCM加密
|
||||
- **传输加密**: WebSocket over HTTPS(生产环境)
|
||||
|
||||
### 认证授权
|
||||
- JWT令牌认证
|
||||
- 路由权限控制
|
||||
- 用户数据隔离
|
||||
|
||||
### 输入验证
|
||||
- 前后端双重验证
|
||||
- SQL注入防护
|
||||
- XSS攻击防护
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 数据库连接失败
|
||||
```bash
|
||||
# 检查MySQL服务状态
|
||||
sudo systemctl status mysql
|
||||
|
||||
# 检查数据库用户权限
|
||||
mysql -u webssh -p webssh
|
||||
```
|
||||
|
||||
#### 2. 加密密钥错误
|
||||
```bash
|
||||
# 确保ENCRYPTION_KEY为32字节
|
||||
node -e "console.log(process.env.ENCRYPTION_KEY.length)"
|
||||
```
|
||||
|
||||
#### 3. 端口冲突
|
||||
```bash
|
||||
# 检查端口占用
|
||||
netstat -tulpn | grep :3000
|
||||
netstat -tulpn | grep :5173
|
||||
```
|
||||
|
||||
#### 4. 依赖安装失败
|
||||
```bash
|
||||
# 清除缓存重新安装
|
||||
npm cache clean --force
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
```bash
|
||||
# 后端日志
|
||||
cd backend
|
||||
npm run dev
|
||||
|
||||
# 前端日志
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [xterm.js](https://xtermjs.org/) - 强大的Web终端组件
|
||||
- [ssh2](https://github.com/mscdex/ssh2) - Node.js SSH客户端库
|
||||
- [Vue.js](https://vuejs.org/) - 渐进式JavaScript框架
|
||||
- [Element Plus](https://element-plus.org/) - Vue3组件库
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
- 提交 Issue
|
||||
- 发送邮件
|
||||
|
||||
---
|
||||
|
||||
**注意**: 本项目仍在开发中,部分功能可能不完善。生产环境使用前请充分测试。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=webssh
|
||||
DB_PASSWORD=webssh
|
||||
DB_NAME=webssh
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=hK9/mP2sR5vX8nL1zQ4wJ7tY3uA6bD0eC+gF/iO=
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Encryption Key (for AES-256) - must be exactly 32 bytes
|
||||
ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456
|
||||
@@ -0,0 +1,16 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=webssh
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Encryption Key (for AES-256)
|
||||
ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
@@ -0,0 +1,31 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
dotenv.config();
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'webssh'
|
||||
});
|
||||
|
||||
console.log('=== 检查用户表 ===');
|
||||
const [users] = await connection.execute('SELECT id, email FROM users');
|
||||
console.log('用户总数:', users.length);
|
||||
users.forEach(user => console.log('用户:', user));
|
||||
|
||||
console.log('\n=== 检查服务器表 ===');
|
||||
const [servers] = await connection.execute('SELECT id, user_id, name, host FROM servers');
|
||||
console.log('服务器总数:', servers.length);
|
||||
servers.forEach(server => console.log('服务器:', server));
|
||||
|
||||
await connection.end();
|
||||
} catch (error) {
|
||||
console.error('数据库连接错误:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
checkDatabase();
|
||||
@@ -0,0 +1,97 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'webssh'
|
||||
};
|
||||
|
||||
// Create connection pool
|
||||
const pool = mysql.createPool({
|
||||
...dbConfig,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
// Test connection
|
||||
const authenticate = async () => {
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
console.log('Database connected successfully');
|
||||
connection.release();
|
||||
|
||||
// Initialize database tables
|
||||
await initializeTables();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database connection failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database tables
|
||||
const initializeTables = async () => {
|
||||
try {
|
||||
// Users table
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Servers table (with encrypted credentials)
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INT DEFAULT 22,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password_encrypted TEXT,
|
||||
private_key_encrypted TEXT,
|
||||
auth_type ENUM('password', 'key') DEFAULT 'password',
|
||||
group_name VARCHAR(255) DEFAULT 'Default',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Connection logs table
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS connection_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
server_id INT,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time TIMESTAMP NULL,
|
||||
duration INT DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database tables initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing database tables:', error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
authenticate,
|
||||
initializeTables
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* JWT认证中间件
|
||||
*/
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '访问令牌不存在'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET || 'webssh-secret-key';
|
||||
const decoded = jwt.verify(token, secret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('JWT验证失败:', error);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateToken
|
||||
};
|
||||
Generated
+1930
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "webssh-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "WebSSH backend server",
|
||||
"scripts": {
|
||||
"dev": "nodemon server.js",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.5",
|
||||
"ssh2": "^1.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.6.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../config/database');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 用户注册
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, phone, password } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '邮箱和密码是必填项' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: '密码长度至少6位' });
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
const [existingUsers] = await db.pool.execute(
|
||||
'SELECT id FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
return res.status(400).json({ error: '该邮箱已被注册' });
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 创建用户
|
||||
const [result] = await db.pool.execute(
|
||||
'INSERT INTO users (email, phone, password_hash) VALUES (?, ?, ?)',
|
||||
[email, phone || null, passwordHash]
|
||||
);
|
||||
|
||||
// 生成JWT令牌
|
||||
const token = jwt.sign(
|
||||
{ userId: result.insertId, email },
|
||||
process.env.JWT_SECRET || 'webssh-secret-key',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: '注册成功',
|
||||
token,
|
||||
user: {
|
||||
id: result.insertId,
|
||||
email,
|
||||
phone
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注册错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 用户登录
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '邮箱和密码是必填项' });
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const [users] = await db.pool.execute(
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ error: '邮箱或密码错误' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// 验证密码
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: '邮箱或密码错误' });
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email },
|
||||
process.env.JWT_SECRET || 'webssh-secret-key',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: '登录成功',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 验证令牌中间件
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: '访问令牌缺失' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET || 'webssh-secret-key', (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: '令牌无效' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// 获取当前用户信息
|
||||
router.get('/me', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [users] = await db.pool.execute(
|
||||
'SELECT id, email, phone, created_at FROM users WHERE id = ?',
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
res.json({ user: users[0] });
|
||||
} catch (error) {
|
||||
console.error('获取用户信息错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 修改密码
|
||||
router.put('/password', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: '当前密码和新密码是必填项' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ error: '新密码长度至少6位' });
|
||||
}
|
||||
|
||||
// 获取用户当前密码
|
||||
const [users] = await db.pool.execute(
|
||||
'SELECT password_hash FROM users WHERE id = ?',
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, users[0].password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: '当前密码错误' });
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const saltRounds = 12;
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// 更新密码
|
||||
await db.pool.execute(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[newPasswordHash, req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ message: '密码修改成功' });
|
||||
} catch (error) {
|
||||
console.error('修改密码错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,378 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../config/database');
|
||||
const encryptionService = require('../utils/encryption');
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 验证令牌中间件
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: '访问令牌缺失' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET || 'webssh-secret-key', (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: '令牌无效' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// 获取用户的所有服务器
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [servers] = await db.pool.execute(
|
||||
`SELECT
|
||||
id, name, host, port, username, auth_type, group_name,
|
||||
created_at, updated_at
|
||||
FROM servers
|
||||
WHERE user_id = ?
|
||||
ORDER BY group_name, name`,
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ servers });
|
||||
} catch (error) {
|
||||
console.error('获取服务器列表错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加新服务器
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { name, host, port, username, password, privateKey, authType, groupName } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !host || !username) {
|
||||
return res.status(400).json({ error: '名称、主机和用户名是必填项' });
|
||||
}
|
||||
|
||||
// 验证认证方式
|
||||
if (authType === 'password' && !password) {
|
||||
return res.status(400).json({ error: '密码认证需要提供密码' });
|
||||
}
|
||||
|
||||
if (authType === 'key' && !privateKey) {
|
||||
return res.status(400).json({ error: '密钥认证需要提供私钥' });
|
||||
}
|
||||
|
||||
// 加密敏感信息
|
||||
let passwordEncrypted = null;
|
||||
let privateKeyEncrypted = null;
|
||||
|
||||
if (authType === 'password' && password) {
|
||||
passwordEncrypted = encryptionService.encryptPassword(password, req.user.userId);
|
||||
}
|
||||
|
||||
if (authType === 'key' && privateKey) {
|
||||
privateKeyEncrypted = encryptionService.encryptPrivateKey(privateKey, req.user.userId);
|
||||
}
|
||||
|
||||
// 插入服务器记录
|
||||
const [result] = await db.pool.execute(
|
||||
`INSERT INTO servers
|
||||
(user_id, name, host, port, username, password_encrypted, private_key_encrypted, auth_type, group_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
req.user.userId,
|
||||
name,
|
||||
host,
|
||||
port || 22,
|
||||
username,
|
||||
passwordEncrypted,
|
||||
privateKeyEncrypted,
|
||||
authType || 'password',
|
||||
groupName || 'Default'
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: '服务器添加成功',
|
||||
server: {
|
||||
id: result.insertId,
|
||||
name,
|
||||
host,
|
||||
port: port || 22,
|
||||
username,
|
||||
authType: authType || 'password',
|
||||
groupName: groupName || 'Default'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('添加服务器错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新服务器信息
|
||||
router.put('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const serverId = req.params.id;
|
||||
const { name, host, port, username, password, privateKey, authType, groupName } = req.body;
|
||||
|
||||
// 检查服务器是否存在且属于当前用户
|
||||
const [servers] = await db.pool.execute(
|
||||
'SELECT id FROM servers WHERE id = ? AND user_id = ?',
|
||||
[serverId, req.user.userId]
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return res.status(404).json({ error: '服务器不存在' });
|
||||
}
|
||||
|
||||
// 构建更新字段
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
|
||||
if (name) {
|
||||
updateFields.push('name = ?');
|
||||
updateValues.push(name);
|
||||
}
|
||||
|
||||
if (host) {
|
||||
updateFields.push('host = ?');
|
||||
updateValues.push(host);
|
||||
}
|
||||
|
||||
if (port) {
|
||||
updateFields.push('port = ?');
|
||||
updateValues.push(port);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
updateFields.push('username = ?');
|
||||
updateValues.push(username);
|
||||
}
|
||||
|
||||
if (groupName) {
|
||||
updateFields.push('group_name = ?');
|
||||
updateValues.push(groupName);
|
||||
}
|
||||
|
||||
// 处理认证信息更新
|
||||
if (authType) {
|
||||
updateFields.push('auth_type = ?');
|
||||
updateValues.push(authType);
|
||||
|
||||
if (authType === 'password' && password) {
|
||||
const passwordEncrypted = encryptionService.encryptPassword(password, req.user.userId);
|
||||
updateFields.push('password_encrypted = ?');
|
||||
updateValues.push(passwordEncrypted);
|
||||
updateFields.push('private_key_encrypted = NULL');
|
||||
} else if (authType === 'key' && privateKey) {
|
||||
const privateKeyEncrypted = encryptionService.encryptPrivateKey(privateKey, req.user.userId);
|
||||
updateFields.push('private_key_encrypted = ?');
|
||||
updateValues.push(privateKeyEncrypted);
|
||||
updateFields.push('password_encrypted = NULL');
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: '没有提供更新信息' });
|
||||
}
|
||||
|
||||
updateValues.push(serverId, req.user.userId);
|
||||
|
||||
await db.pool.execute(
|
||||
`UPDATE servers SET ${updateFields.join(', ')}
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
updateValues
|
||||
);
|
||||
|
||||
res.json({ message: '服务器更新成功' });
|
||||
} catch (error) {
|
||||
console.error('更新服务器错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除服务器
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const serverId = req.params.id;
|
||||
|
||||
const [result] = await db.pool.execute(
|
||||
'DELETE FROM servers WHERE id = ? AND user_id = ?',
|
||||
[serverId, req.user.userId]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: '服务器不存在' });
|
||||
}
|
||||
|
||||
res.json({ message: '服务器删除成功' });
|
||||
} catch (error) {
|
||||
console.error('删除服务器错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取服务器分组
|
||||
router.get('/groups', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [groups] = await db.pool.execute(
|
||||
'SELECT DISTINCT group_name FROM servers WHERE user_id = ? ORDER BY group_name',
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
const groupNames = groups.map(group => group.group_name);
|
||||
res.json({ groups: groupNames });
|
||||
} catch (error) {
|
||||
console.error('获取服务器分组错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取服务器详情(包含解密后的认证信息)
|
||||
router.get('/:id/credentials', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const serverId = req.params.id;
|
||||
|
||||
const [servers] = await db.pool.execute(
|
||||
`SELECT id, name, host, port, username, password_encrypted,
|
||||
private_key_encrypted, auth_type, group_name
|
||||
FROM servers WHERE id = ? AND user_id = ?`,
|
||||
[serverId, req.user.userId]
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return res.status(404).json({ error: '服务器不存在' });
|
||||
}
|
||||
|
||||
const server = servers[0];
|
||||
let credentials = {};
|
||||
|
||||
// 解密认证信息
|
||||
if (server.auth_type === 'password' && server.password_encrypted) {
|
||||
credentials.password = encryptionService.decryptPassword(
|
||||
server.password_encrypted,
|
||||
req.user.userId
|
||||
);
|
||||
} else if (server.auth_type === 'key' && server.private_key_encrypted) {
|
||||
credentials.privateKey = encryptionService.decryptPrivateKey(
|
||||
server.private_key_encrypted,
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
server: {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
authType: server.auth_type,
|
||||
groupName: server.group_name
|
||||
},
|
||||
credentials
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取服务器认证信息错误:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 测试服务器连接(使用当前表单数据)
|
||||
router.post('/test-connection', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
host,
|
||||
port = 22,
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
authType = 'password'
|
||||
} = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!host || !username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '主机地址和用户名是必填项'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证认证信息
|
||||
if (authType === 'password' && !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '密码认证需要提供密码'
|
||||
});
|
||||
}
|
||||
|
||||
if (authType === 'key' && !privateKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '密钥认证需要提供私钥'
|
||||
});
|
||||
}
|
||||
|
||||
// 创建SSH连接配置
|
||||
const sshConfig = {
|
||||
host: host,
|
||||
port: parseInt(port) || 22,
|
||||
username: username,
|
||||
readyTimeout: 10000, // 10秒超时
|
||||
};
|
||||
|
||||
// 添加认证信息
|
||||
if (authType === 'password' && password) {
|
||||
sshConfig.password = password;
|
||||
} else if (authType === 'key' && privateKey) {
|
||||
sshConfig.privateKey = privateKey;
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const conn = new Client();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
res.status(408).json({
|
||||
success: false,
|
||||
error: '连接超时'
|
||||
});
|
||||
resolve();
|
||||
}, 10000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
res.json({
|
||||
success: true,
|
||||
message: '连接测试成功'
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error('SSH连接测试错误:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `连接失败: ${err.message || '未知错误'}`
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
conn.connect(sshConfig);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('测试连接错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,181 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const SftpHandler = require('../utils/sftpHandler');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { getServerById } = require('../utils/database');
|
||||
const { decrypt } = require('../utils/encryption');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 配置multer用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const tempDir = path.join(__dirname, '../temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
cb(null, tempDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, Date.now() + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024 // 100MB限制
|
||||
}
|
||||
});
|
||||
|
||||
// 文件上传
|
||||
router.post('/upload', authenticateToken, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const { serverId, path: remotePath } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '没有选择文件'
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverId || !remotePath) {
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '服务器ID和路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取服务器配置
|
||||
const server = await getServerById(serverId);
|
||||
if (!server || server.user_id !== userId) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '服务器不存在或无权访问'
|
||||
});
|
||||
}
|
||||
|
||||
// 解密敏感信息
|
||||
const serverConfig = {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password ? decrypt(server.password, userId) : null,
|
||||
privateKey: server.private_key ? decrypt(server.private_key, userId) : null
|
||||
};
|
||||
|
||||
// 连接SFTP并上传文件
|
||||
const sftpHandler = new SftpHandler(serverConfig);
|
||||
await sftpHandler.connect();
|
||||
await sftpHandler.uploadFile(req.file.path, remotePath);
|
||||
await sftpHandler.disconnect();
|
||||
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '文件上传成功',
|
||||
filename: req.file.originalname,
|
||||
size: req.file.size
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('文件上传错误:', error);
|
||||
|
||||
// 清理临时文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件上传失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 文件下载
|
||||
router.get('/download', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path: remotePath } = req.query;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!serverId || !remotePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '服务器ID和路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取服务器配置
|
||||
const server = await getServerById(serverId);
|
||||
if (!server || server.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '服务器不存在或无权访问'
|
||||
});
|
||||
}
|
||||
|
||||
// 解密敏感信息
|
||||
const serverConfig = {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password ? decrypt(server.password, userId) : null,
|
||||
privateKey: server.private_key ? decrypt(server.private_key, userId) : null
|
||||
};
|
||||
|
||||
// 创建临时目录
|
||||
const tempDir = path.join(__dirname, '../temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tempFilePath = path.join(tempDir, `download_${Date.now()}_${path.basename(remotePath)}`);
|
||||
|
||||
// 连接SFTP并下载文件
|
||||
const sftpHandler = new SftpHandler(serverConfig);
|
||||
await sftpHandler.connect();
|
||||
await sftpHandler.downloadFile(remotePath, tempFilePath);
|
||||
await sftpHandler.disconnect();
|
||||
|
||||
// 设置响应头
|
||||
const filename = path.basename(remotePath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
|
||||
// 发送文件
|
||||
const fileStream = fs.createReadStream(tempFilePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
// 文件传输完成后清理临时文件
|
||||
fileStream.on('end', () => {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.on('error', () => {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('文件下载错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件下载失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,431 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const router = express.Router();
|
||||
const SftpHandler = require('../utils/sftpHandler');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { getServerById } = require('../utils/database');
|
||||
const encryptionService = require('../utils/encryption');
|
||||
|
||||
// 配置multer用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const tempDir = path.join(__dirname, '../temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
cb(null, tempDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, Date.now() + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024 // 100MB限制
|
||||
}
|
||||
});
|
||||
|
||||
// 存储活跃的SFTP连接
|
||||
const sftpConnections = new Map();
|
||||
|
||||
// 获取SFTP连接
|
||||
async function getSftpConnection(userId, serverId) {
|
||||
const key = `${userId}-${serverId}`;
|
||||
|
||||
if (sftpConnections.has(key)) {
|
||||
const connection = sftpConnections.get(key);
|
||||
if (connection.isConnected) {
|
||||
return connection;
|
||||
}
|
||||
sftpConnections.delete(key);
|
||||
}
|
||||
|
||||
// 获取服务器配置
|
||||
const server = await getServerById(serverId);
|
||||
if (!server || server.user_id !== userId) {
|
||||
throw new Error('服务器不存在或无权访问');
|
||||
}
|
||||
|
||||
// 解密敏感信息
|
||||
const serverConfig = {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
};
|
||||
|
||||
if (server.password_encrypted) {
|
||||
serverConfig.password = encryptionService.decryptPassword(
|
||||
server.password_encrypted,
|
||||
userId
|
||||
);
|
||||
}
|
||||
if (server.private_key_encrypted) {
|
||||
serverConfig.privateKey = encryptionService.decryptPrivateKey(
|
||||
server.private_key_encrypted,
|
||||
userId
|
||||
);
|
||||
};
|
||||
|
||||
const sftpHandler = new SftpHandler(serverConfig);
|
||||
await sftpHandler.connect();
|
||||
|
||||
sftpConnections.set(key, sftpHandler);
|
||||
|
||||
// 设置定时器自动断开连接(10分钟无活动)
|
||||
setTimeout(() => {
|
||||
if (sftpConnections.has(key)) {
|
||||
sftpConnections.get(key).disconnect();
|
||||
sftpConnections.delete(key);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
return sftpHandler;
|
||||
}
|
||||
|
||||
// 连接SFTP
|
||||
router.post('/connect', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
console.log('SFTP连接请求 - 用户ID:', userId, '服务器ID:', serverId);
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
|
||||
// 获取根目录列表
|
||||
const files = await sftpHandler.listDirectory('.');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'SFTP连接成功',
|
||||
currentPath: '/',
|
||||
files: files
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SFTP连接错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'SFTP连接失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 断开SFTP连接
|
||||
router.post('/disconnect', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId } = req.body;
|
||||
const userId = req.user.userId;
|
||||
const key = `${userId}-${serverId}`;
|
||||
|
||||
if (sftpConnections.has(key)) {
|
||||
await sftpConnections.get(key).disconnect();
|
||||
sftpConnections.delete(key);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'SFTP连接已断开'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('断开SFTP连接错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '断开连接失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 列出目录内容
|
||||
router.get('/list', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path = '.' } = req.query;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
const files = await sftpHandler.listDirectory(path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: path,
|
||||
files: files
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('列出目录错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '列出目录失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 读取文件内容
|
||||
router.get('/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path } = req.query;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
const content = await sftpHandler.getFileContent(path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
content: content,
|
||||
path: path
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('读取文件错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '读取文件失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存文件内容
|
||||
router.post('/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path, content } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!path || content === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径和内容不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
|
||||
// 创建临时文件保存内容,然后上传
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const pathModule = require('path');
|
||||
|
||||
const tempPath = pathModule.join(os.tmpdir(), `sftp_edit_${Date.now()}.tmp`);
|
||||
fs.writeFileSync(tempPath, content, 'utf8');
|
||||
|
||||
await sftpHandler.uploadFile(tempPath, path);
|
||||
fs.unlinkSync(tempPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '文件保存成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存文件错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '保存文件失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建目录
|
||||
router.post('/mkdir', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '目录路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
await sftpHandler.createDirectory(path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '目录创建成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建目录错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '创建目录失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除文件或目录
|
||||
router.delete('/delete', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path, type } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
|
||||
if (type === 'directory') {
|
||||
await sftpHandler.deleteDirectory(path);
|
||||
} else {
|
||||
await sftpHandler.deleteFile(path);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '删除失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 重命名文件或目录
|
||||
router.post('/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, oldPath, newPath } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '原路径和新路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
await sftpHandler.renameFile(oldPath, newPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '重命名成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('重命名错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '重命名失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 文件上传
|
||||
router.post('/upload', authenticateToken, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const { serverId, path: remotePath } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '没有选择文件'
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverId || !remotePath) {
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '服务器ID和路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
await sftpHandler.uploadFile(req.file.path, remotePath);
|
||||
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '文件上传成功',
|
||||
filename: req.file.originalname,
|
||||
size: req.file.size
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('文件上传错误:', error);
|
||||
|
||||
// 清理临时文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件上传失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 文件下载
|
||||
router.get('/download', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { serverId, path: remotePath } = req.query;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!serverId || !remotePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '服务器ID和路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 创建临时目录
|
||||
const tempDir = path.join(__dirname, '../temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tempFilePath = path.join(tempDir, `download_${Date.now()}_${path.basename(remotePath)}`);
|
||||
|
||||
const sftpHandler = await getSftpConnection(userId, serverId);
|
||||
await sftpHandler.downloadFile(remotePath, tempFilePath);
|
||||
|
||||
// 设置响应头
|
||||
const filename = path.basename(remotePath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
|
||||
// 发送文件
|
||||
const fileStream = fs.createReadStream(tempFilePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
// 文件传输完成后清理临时文件
|
||||
fileStream.on('end', () => {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.on('error', () => {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('文件下载错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件下载失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,61 @@
|
||||
const express = require('express');
|
||||
const { createServer } = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const cors = require('cors');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth');
|
||||
const serverRoutes = require('./routes/servers');
|
||||
const sftpRoutes = require('./routes/sftp');
|
||||
|
||||
// Database
|
||||
const db = require('./config/database');
|
||||
|
||||
// Socket handlers
|
||||
const socketHandler = require('./socket/socketHandler');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "http://localhost:5173", // Vite dev server
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/servers', serverRoutes);
|
||||
app.use('/api/sftp', sftpRoutes);
|
||||
|
||||
// Socket.io connection handling
|
||||
socketHandler(io);
|
||||
|
||||
// Serve frontend for production
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||
});
|
||||
|
||||
// Database connection test
|
||||
db.authenticate()
|
||||
.then(() => {
|
||||
console.log('Database connection established successfully.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Unable to connect to the database:', err);
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`WebSSH server running on port ${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
const { Client } = require('ssh2');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../config/database');
|
||||
const encryptionService = require('../utils/encryption');
|
||||
|
||||
// 存储活跃的SSH连接
|
||||
const activeConnections = new Map();
|
||||
|
||||
const socketHandler = (io) => {
|
||||
io.on('connection', (socket) => {
|
||||
console.log('用户连接:', socket.id);
|
||||
|
||||
// 验证用户身份
|
||||
socket.on('authenticate', async (token) => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'webssh-secret-key');
|
||||
socket.userId = decoded.userId;
|
||||
socket.emit('authenticated', { success: true });
|
||||
console.log('用户认证成功:', decoded.userId);
|
||||
} catch (error) {
|
||||
socket.emit('authenticated', { success: false, error: '认证失败' });
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 建立SSH连接
|
||||
socket.on('connect-ssh', async (serverId) => {
|
||||
if (!socket.userId) {
|
||||
socket.emit('ssh-error', { error: '未认证' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取服务器信息和认证信息
|
||||
const [servers] = await db.pool.execute(
|
||||
`SELECT id, name, host, port, username, password_encrypted,
|
||||
private_key_encrypted, auth_type
|
||||
FROM servers WHERE id = ? AND user_id = ?`,
|
||||
[serverId, socket.userId]
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
socket.emit('ssh-error', { error: '服务器不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const server = servers[0];
|
||||
|
||||
// 解密认证信息
|
||||
let credentials = {};
|
||||
if (server.auth_type === 'password' && server.password_encrypted) {
|
||||
credentials.password = encryptionService.decryptPassword(
|
||||
server.password_encrypted,
|
||||
socket.userId
|
||||
);
|
||||
} else if (server.auth_type === 'key' && server.private_key_encrypted) {
|
||||
credentials.privateKey = encryptionService.decryptPrivateKey(
|
||||
server.private_key_encrypted,
|
||||
socket.userId
|
||||
);
|
||||
}
|
||||
|
||||
// 创建SSH连接
|
||||
const conn = new Client();
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log('SSH连接已建立:', server.host);
|
||||
socket.emit('ssh-connected');
|
||||
|
||||
// 存储连接
|
||||
activeConnections.set(socket.id, {
|
||||
conn,
|
||||
serverId: server.id,
|
||||
userId: socket.userId
|
||||
});
|
||||
|
||||
// 创建shell会话
|
||||
conn.shell({}, (err, stream) => {
|
||||
if (err) {
|
||||
socket.emit('ssh-error', { error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// 转发终端输出到前端
|
||||
stream.on('data', (data) => {
|
||||
socket.emit('ssh-data', data.toString());
|
||||
});
|
||||
|
||||
// 处理来自前端的输入
|
||||
socket.on('ssh-input', (input) => {
|
||||
stream.write(input);
|
||||
});
|
||||
|
||||
// 处理窗口大小调整
|
||||
socket.on('resize', (size) => {
|
||||
stream.setWindow(size.rows, size.cols, size.height, size.width);
|
||||
});
|
||||
|
||||
// 处理连接关闭
|
||||
stream.on('close', () => {
|
||||
socket.emit('ssh-closed');
|
||||
activeConnections.delete(socket.id);
|
||||
});
|
||||
|
||||
socket.sshStream = stream;
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error('SSH连接错误:', err.message);
|
||||
socket.emit('ssh-error', { error: err.message });
|
||||
});
|
||||
|
||||
conn.on('close', () => {
|
||||
console.log('SSH连接已关闭:', server.host);
|
||||
socket.emit('ssh-closed');
|
||||
activeConnections.delete(socket.id);
|
||||
});
|
||||
|
||||
// 连接配置
|
||||
const connConfig = {
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 30000
|
||||
};
|
||||
|
||||
// 添加认证信息
|
||||
if (server.auth_type === 'password') {
|
||||
connConfig.password = credentials.password;
|
||||
} else if (server.auth_type === 'key') {
|
||||
connConfig.privateKey = credentials.privateKey;
|
||||
}
|
||||
|
||||
// 开始连接
|
||||
conn.connect(connConfig);
|
||||
|
||||
} catch (error) {
|
||||
console.error('SSH连接准备错误:', error);
|
||||
socket.emit('ssh-error', { error: '连接失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 快速连接(不保存服务器信息)
|
||||
socket.on('quick-connect', (connectionInfo) => {
|
||||
if (!socket.userId) {
|
||||
socket.emit('ssh-error', { error: '未认证' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { host, port, username, password, privateKey } = connectionInfo;
|
||||
|
||||
const conn = new Client();
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log('快速SSH连接已建立:', host);
|
||||
socket.emit('ssh-connected');
|
||||
|
||||
// 存储连接
|
||||
activeConnections.set(socket.id, {
|
||||
conn,
|
||||
quickConnect: true,
|
||||
userId: socket.userId
|
||||
});
|
||||
|
||||
// 创建shell会话
|
||||
conn.shell({}, (err, stream) => {
|
||||
if (err) {
|
||||
socket.emit('ssh-error', { error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('data', (data) => {
|
||||
socket.emit('ssh-data', data.toString());
|
||||
});
|
||||
|
||||
socket.on('ssh-input', (input) => {
|
||||
stream.write(input);
|
||||
});
|
||||
|
||||
socket.on('resize', (size) => {
|
||||
stream.setWindow(size.rows, size.cols, size.height, size.width);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
socket.emit('ssh-closed');
|
||||
activeConnections.delete(socket.id);
|
||||
});
|
||||
|
||||
socket.sshStream = stream;
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error('快速SSH连接错误:', err.message);
|
||||
socket.emit('ssh-error', { error: err.message });
|
||||
});
|
||||
|
||||
conn.on('close', () => {
|
||||
console.log('快速SSH连接已关闭:', host);
|
||||
socket.emit('ssh-closed');
|
||||
activeConnections.delete(socket.id);
|
||||
});
|
||||
|
||||
// 连接配置
|
||||
const connConfig = {
|
||||
host,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 30000
|
||||
};
|
||||
|
||||
if (password) {
|
||||
connConfig.password = password;
|
||||
} else if (privateKey) {
|
||||
connConfig.privateKey = privateKey;
|
||||
}
|
||||
|
||||
conn.connect(connConfig);
|
||||
});
|
||||
|
||||
// 发送命令到终端
|
||||
socket.on('send-command', (command) => {
|
||||
if (socket.sshStream) {
|
||||
socket.sshStream.write(command + '\n');
|
||||
}
|
||||
});
|
||||
|
||||
// 断开SSH连接
|
||||
socket.on('disconnect-ssh', () => {
|
||||
const connection = activeConnections.get(socket.id);
|
||||
if (connection) {
|
||||
connection.conn.end();
|
||||
activeConnections.delete(socket.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('用户断开连接:', socket.id);
|
||||
const connection = activeConnections.get(socket.id);
|
||||
if (connection) {
|
||||
connection.conn.end();
|
||||
activeConnections.delete(socket.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 心跳检测
|
||||
socket.on('ping', () => {
|
||||
socket.emit('pong');
|
||||
});
|
||||
});
|
||||
|
||||
// 全局错误处理
|
||||
io.on('error', (error) => {
|
||||
console.error('Socket.IO错误:', error);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = socketHandler;
|
||||
@@ -0,0 +1,107 @@
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// 通用查询函数
|
||||
async function query(sql, params = []) {
|
||||
try {
|
||||
const [rows] = await pool.execute(sql, params);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('数据库查询错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据ID获取服务器
|
||||
async function getServerById(id) {
|
||||
const sql = 'SELECT * FROM servers WHERE id = ?';
|
||||
const result = await query(sql, [id]);
|
||||
return result.length > 0 ? result[0] : null;
|
||||
}
|
||||
|
||||
// 获取用户的所有服务器
|
||||
async function getUserServers(userId) {
|
||||
const sql = 'SELECT * FROM servers WHERE user_id = ? ORDER BY name';
|
||||
return await query(sql, [userId]);
|
||||
}
|
||||
|
||||
// 创建服务器
|
||||
async function createServer(serverData) {
|
||||
const sql = `
|
||||
INSERT INTO servers (user_id, name, host, port, username, password_encrypted, private_key_encrypted, group_name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`;
|
||||
const result = await query(sql, [
|
||||
serverData.user_id,
|
||||
serverData.name,
|
||||
serverData.host,
|
||||
serverData.port,
|
||||
serverData.username,
|
||||
serverData.password,
|
||||
serverData.private_key,
|
||||
serverData.group_name || 'default'
|
||||
]);
|
||||
|
||||
return { id: result.insertId, ...serverData };
|
||||
}
|
||||
|
||||
// 更新服务器
|
||||
async function updateServer(id, serverData) {
|
||||
const sql = `
|
||||
UPDATE servers
|
||||
SET name = ?, host = ?, port = ?, username = ?, password_encrypted = ?, private_key_encrypted = ?, group_name = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await query(sql, [
|
||||
serverData.name,
|
||||
serverData.host,
|
||||
serverData.port,
|
||||
serverData.username,
|
||||
serverData.password,
|
||||
serverData.private_key,
|
||||
serverData.group_name,
|
||||
id
|
||||
]);
|
||||
|
||||
return { id, ...serverData };
|
||||
}
|
||||
|
||||
// 删除服务器
|
||||
async function deleteServer(id) {
|
||||
const sql = 'DELETE FROM servers WHERE id = ?';
|
||||
await query(sql, [id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async function getUserByEmail(email) {
|
||||
const sql = 'SELECT * FROM users WHERE email = ?';
|
||||
const result = await query(sql, [email]);
|
||||
return result.length > 0 ? result[0] : null;
|
||||
}
|
||||
|
||||
async function getUserById(id) {
|
||||
const sql = 'SELECT id, email, created_at FROM users WHERE id = ?';
|
||||
const result = await query(sql, [id]);
|
||||
return result.length > 0 ? result[0] : null;
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
async function createUser(userData) {
|
||||
const sql = 'INSERT INTO users (email, password, created_at) VALUES (?, ?, NOW())';
|
||||
const result = await query(sql, [userData.email, userData.password]);
|
||||
return { id: result.insertId, email: userData.email };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
query,
|
||||
getServerById,
|
||||
getUserServers,
|
||||
createServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
createUser
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
const crypto = require('crypto');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
class EncryptionService {
|
||||
constructor() {
|
||||
this.encryptionKey = process.env.ENCRYPTION_KEY || 'default-encryption-key-32bytes-long';
|
||||
if (!this.encryptionKey || this.encryptionKey.length !== 32) {
|
||||
throw new Error('ENCRYPTION_KEY must be exactly 32 bytes long');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户特定的加密密钥(基于用户ID)
|
||||
*/
|
||||
generateUserKey(userId) {
|
||||
const userKey = crypto.createHash('sha256')
|
||||
.update(this.encryptionKey + userId.toString())
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
return userKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
encrypt(data, userId) {
|
||||
if (!data) return null;
|
||||
|
||||
const userKey = this.generateUserKey(userId);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, userKey, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 返回 IV + AuthTag + EncryptedData
|
||||
return iv.toString('hex') + authTag.toString('hex') + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
decrypt(encryptedData, userId) {
|
||||
if (!encryptedData) return null;
|
||||
|
||||
try {
|
||||
const userKey = this.generateUserKey(userId);
|
||||
|
||||
// 提取 IV、AuthTag 和加密数据
|
||||
const iv = Buffer.from(encryptedData.substring(0, IV_LENGTH * 2), 'hex');
|
||||
const authTag = Buffer.from(
|
||||
encryptedData.substring(IV_LENGTH * 2, IV_LENGTH * 2 + AUTH_TAG_LENGTH * 2),
|
||||
'hex'
|
||||
);
|
||||
const encrypted = encryptedData.substring(IV_LENGTH * 2 + AUTH_TAG_LENGTH * 2);
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, userKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密服务器密码
|
||||
*/
|
||||
encryptPassword(password, userId) {
|
||||
return this.encrypt(password, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密服务器密码
|
||||
*/
|
||||
decryptPassword(encryptedPassword, userId) {
|
||||
return this.decrypt(encryptedPassword, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密私钥
|
||||
*/
|
||||
encryptPrivateKey(privateKey, userId) {
|
||||
return this.encrypt(privateKey, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密私钥
|
||||
*/
|
||||
decryptPrivateKey(encryptedPrivateKey, userId) {
|
||||
return this.decrypt(encryptedPrivateKey, userId);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EncryptionService();
|
||||
@@ -0,0 +1,215 @@
|
||||
const { Client } = require('ssh2');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
class SftpHandler {
|
||||
constructor(serverConfig) {
|
||||
this.serverConfig = serverConfig;
|
||||
this.sshClient = new Client();
|
||||
this.sftp = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sshClient.on('ready', () => {
|
||||
this.sshClient.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
this.sftp = sftp;
|
||||
this.isConnected = true;
|
||||
resolve(sftp);
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
reject(err);
|
||||
}).connect({
|
||||
host: this.serverConfig.host,
|
||||
port: this.serverConfig.port || 22,
|
||||
username: this.serverConfig.username,
|
||||
password: this.serverConfig.password,
|
||||
privateKey: this.serverConfig.privateKey,
|
||||
readyTimeout: 20000
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.sshClient) {
|
||||
this.sshClient.end();
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.sftp = null;
|
||||
}
|
||||
|
||||
async listDirectory(directory = '.') {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.readdir(directory, (err, list) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = list.map(item => {
|
||||
const isDirectory = item.longname.startsWith('d');
|
||||
const isSymlink = item.longname.startsWith('l');
|
||||
|
||||
return {
|
||||
name: item.filename,
|
||||
path: path.posix.join(directory === '.' ? '' : directory, item.filename),
|
||||
type: isDirectory ? 'directory' : (isSymlink ? 'symlink' : 'file'),
|
||||
size: item.attrs.size,
|
||||
modified: item.attrs.mtime,
|
||||
permissions: item.attrs.mode.toString(8).slice(-3),
|
||||
owner: item.attrs.uid,
|
||||
group: item.attrs.gid
|
||||
};
|
||||
});
|
||||
|
||||
// 排序:目录在前,文件在后
|
||||
files.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
resolve(files);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getFileContent(filePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.readFile(filePath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(localPath, remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.fastPut(localPath, remotePath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async downloadFile(remotePath, localPath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.fastGet(remotePath, localPath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createDirectory(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.mkdir(remotePath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.unlink(remotePath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDirectory(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.rmdir(remotePath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async renameFile(oldPath, newPath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.rename(oldPath, newPath, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getFileStats(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('SFTP not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.stat(remotePath, (err, stats) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SftpHandler;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebSSH - 在线SSH终端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1481
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "webssh-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.3.14",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"vite": "^4.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化认证状态
|
||||
authStore.initializeAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
line-height: 1.2;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.terminal-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background-color: #ffffff;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="serverForm" label-width="100px">
|
||||
<el-form-item label="服务器名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入服务器名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主机地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="请输入IP地址或域名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入SSH用户名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="认证方式" prop="authType">
|
||||
<el-radio-group v-model="form.authType">
|
||||
<el-radio label="password">密码认证</el-radio>
|
||||
<el-radio label="key">私钥认证</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.authType === 'password'" label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入SSH密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.authType === 'key'" label="私钥" prop="privateKey">
|
||||
<el-input
|
||||
v-model="form.privateKey"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入SSH私钥内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分组" prop="groupName">
|
||||
<el-select v-model="form.groupName" placeholder="请选择分组" style="width: 100%" @change="handleGroupChange">
|
||||
<el-option v-for="group in existingGroups" :key="group" :label="group" :value="group" />
|
||||
<el-option label="新建分组" value="" style="color: #409eff; font-weight: bold;">
|
||||
<span style="color: #409eff; font-weight: bold;">+ 新建分组</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div v-if="form.groupName === ''" style="margin-top: 8px;">
|
||||
<el-input
|
||||
v-model="newGroupName"
|
||||
placeholder="请输入新分组名称"
|
||||
ref="newGroupInput"
|
||||
@blur="handleNewGroupBlur"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
<el-button v-if="serverId" type="success" :loading="testingConnection" @click="handleTestConnection">
|
||||
<el-icon><Connection /></el-icon>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button @click="$emit('cancel')">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted, computed, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import { useServersStore } from '@/stores/servers'
|
||||
|
||||
const props = defineProps({
|
||||
serverId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'cancel'])
|
||||
|
||||
const serversStore = useServersStore()
|
||||
const serverForm = ref()
|
||||
const loading = ref(false)
|
||||
const testingConnection = ref(false)
|
||||
const newGroupName = ref('')
|
||||
const newGroupInput = ref()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
groupName: 'Default'
|
||||
})
|
||||
|
||||
// 获取已存在的分组列表
|
||||
const existingGroups = computed(() => {
|
||||
const groups = new Set()
|
||||
serversStore.servers.forEach(server => {
|
||||
// 兼容 groupName 和 group_name 字段
|
||||
const groupName = server.groupName || server.group_name
|
||||
if (groupName) {
|
||||
groups.add(groupName)
|
||||
}
|
||||
})
|
||||
return Array.from(groups)
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入服务器名称', trigger: 'blur' }
|
||||
],
|
||||
host: [
|
||||
{ required: true, message: '请输入主机地址', trigger: 'blur' }
|
||||
],
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: !props.serverId, // 编辑模式下非必填
|
||||
message: '请输入密码',
|
||||
trigger: 'blur',
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.authType === 'password' && !value && !props.serverId) {
|
||||
callback(new Error('请输入密码'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
privateKey: [
|
||||
{
|
||||
required: !props.serverId, // 编辑模式下非必填
|
||||
message: '请输入私钥',
|
||||
trigger: 'blur',
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.authType === 'key' && !value && !props.serverId) {
|
||||
callback(new Error('请输入私钥'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 监听认证方式变化,清空另一个认证字段
|
||||
watch(() => form.authType, (newVal) => {
|
||||
if (newVal === 'password') {
|
||||
form.privateKey = ''
|
||||
} else {
|
||||
form.password = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听新建分组名称变化 - 移除自动更新,改为手动触发
|
||||
|
||||
// 监听分组选择变化
|
||||
watch(() => form.groupName, (newGroup) => {
|
||||
if (newGroup && newGroup !== '') {
|
||||
newGroupName.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 处理分组选择变化
|
||||
const handleGroupChange = (selectedGroup) => {
|
||||
if (selectedGroup === '' && newGroupInput.value) {
|
||||
// 选择"新建分组"时,延迟聚焦到输入框,确保DOM已更新
|
||||
nextTick(() => {
|
||||
if (newGroupInput.value) {
|
||||
newGroupInput.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理新建分组输入框失去焦点
|
||||
const handleNewGroupBlur = () => {
|
||||
if (newGroupName.value && form.groupName === '') {
|
||||
form.groupName = newGroupName.value
|
||||
}
|
||||
}
|
||||
|
||||
// 加载服务器数据(编辑模式)
|
||||
const loadServerData = async () => {
|
||||
if (!props.serverId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await serversStore.getServerCredentials(props.serverId)
|
||||
if (result.success) {
|
||||
const { server, credentials } = result.data
|
||||
|
||||
form.name = server.name
|
||||
form.host = server.host
|
||||
form.port = server.port
|
||||
form.username = server.username
|
||||
form.authType = server.authType
|
||||
form.groupName = server.groupName
|
||||
|
||||
if (server.authType === 'password') {
|
||||
form.password = credentials.password || ''
|
||||
} else if (server.authType === 'key') {
|
||||
form.privateKey = credentials.privateKey || ''
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载服务器数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
if (props.serverId) {
|
||||
loadServerData()
|
||||
}
|
||||
})
|
||||
|
||||
// 测试服务器连接
|
||||
const handleTestConnection = async () => {
|
||||
// 验证表单必填字段
|
||||
if (!form.host || !form.username) {
|
||||
ElMessage.warning('请填写主机地址和用户名')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证认证信息
|
||||
if (form.authType === 'password' && !form.password) {
|
||||
ElMessage.warning('请填写密码')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.authType === 'key' && !form.privateKey) {
|
||||
ElMessage.warning('请填写私钥')
|
||||
return
|
||||
}
|
||||
|
||||
testingConnection.value = true
|
||||
try {
|
||||
// 准备连接测试数据
|
||||
const connectionData = {
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
authType: form.authType,
|
||||
password: form.password,
|
||||
privateKey: form.privateKey
|
||||
}
|
||||
|
||||
const result = await serversStore.testConnection(connectionData)
|
||||
if (result.success) {
|
||||
ElMessage.success(result.data.message || '连接测试成功')
|
||||
} else {
|
||||
ElMessage.error(result.error || '连接测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('连接测试失败,请重试')
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!serverForm.value) return
|
||||
|
||||
try {
|
||||
const valid = await serverForm.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 处理分组名称:如果选择了"新建分组",使用新输入的分组名
|
||||
let finalGroupName = form.groupName
|
||||
if (form.groupName === '' && newGroupName.value) {
|
||||
finalGroupName = newGroupName.value
|
||||
}
|
||||
|
||||
const serverData = {
|
||||
name: form.name,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
authType: form.authType,
|
||||
groupName: finalGroupName || 'Default'
|
||||
}
|
||||
|
||||
// 只有在提供了新密码或私钥时才更新认证信息
|
||||
if (form.authType === 'password' && form.password) {
|
||||
serverData.password = form.password
|
||||
} else if (form.authType === 'key' && form.privateKey) {
|
||||
serverData.privateKey = form.privateKey
|
||||
}
|
||||
|
||||
let result
|
||||
if (props.serverId) {
|
||||
// 编辑模式
|
||||
result = await serversStore.updateServer(props.serverId, serverData)
|
||||
} else {
|
||||
// 添加模式
|
||||
result = await serversStore.addServer(serverData)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(props.serverId ? '服务器更新成功' : '服务器添加成功')
|
||||
|
||||
// 重置表单(仅在添加模式)
|
||||
if (!props.serverId) {
|
||||
Object.keys(form).forEach(key => {
|
||||
if (key === 'port') {
|
||||
form[key] = 22
|
||||
} else if (key === 'authType') {
|
||||
form[key] = 'password'
|
||||
} else if (key === 'groupName') {
|
||||
form[key] = 'Default'
|
||||
} else {
|
||||
form[key] = ''
|
||||
}
|
||||
})
|
||||
newGroupName.value = ''
|
||||
}
|
||||
|
||||
emit('success')
|
||||
} else {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="sftp-file-manager">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-buttons">
|
||||
<el-button size="small" @click="goToParentDirectory" :disabled="currentPath === '/'">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
返回上级
|
||||
</el-button>
|
||||
<el-button size="small" @click="refreshDirectory">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="showCreateDirDialog = true">
|
||||
<el-icon><FolderAdd /></el-icon>
|
||||
新建文件夹
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文件
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleDeleteSelected"
|
||||
:disabled="selectedFiles.size === 0"
|
||||
type="danger"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除选中
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="path-display">
|
||||
<span>当前路径: {{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div class="file-list-container">
|
||||
<div v-if="loading" class="loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
<el-alert :title="error" type="error" show-icon />
|
||||
</div>
|
||||
|
||||
<div v-else class="file-list">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
class="file-item"
|
||||
:class="{
|
||||
'selected': selectedFiles.has(file.path),
|
||||
'directory': file.type === 'directory'
|
||||
}"
|
||||
@click="handleFileClick(file)"
|
||||
@dblclick="handleFileDoubleClick(file)"
|
||||
@contextmenu.prevent="handleFileContextMenu($event, file)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<el-icon v-if="file.type === 'directory'"><Folder /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.txt')"><Document /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.js')"><Document /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.py')"><Document /></el-icon>
|
||||
<el-icon v-else><Document /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-details">
|
||||
<span v-if="file.type === 'file'">{{ formatFileSize(file.size) }}</span>
|
||||
<span v-else>目录</span>
|
||||
<span>{{ formatDate(file.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-actions">
|
||||
<el-button
|
||||
v-if="file.type === 'file'"
|
||||
size="small"
|
||||
@click.stop="handleDownload(file)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click.stop="handleDelete(file)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="files.length === 0" class="empty-directory">
|
||||
<el-empty description="目录为空" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传输入 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<el-dialog v-model="showCreateDirDialog" title="新建文件夹" width="400px">
|
||||
<el-form :model="createDirForm" label-width="80px">
|
||||
<el-form-item label="文件夹名">
|
||||
<el-input v-model="createDirForm.name" placeholder="请输入文件夹名称" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDirDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateDirectory">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showRenameDialog" title="重命名" width="400px">
|
||||
<el-form :model="renameForm" label-width="80px">
|
||||
<el-form-item label="新名称">
|
||||
<el-input v-model="renameForm.newName" :placeholder="renameForm.oldName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRenameDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleRename">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="编辑文件" width="80%" fullscreen>
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<span>正在编辑: {{ editFile.path }}</span>
|
||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="editFile.content"
|
||||
class="file-editor"
|
||||
placeholder="文件内容..."
|
||||
></textarea>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||
import { useSftpStore } from '../stores/sftp';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const sftpStore = useSftpStore();
|
||||
const fileInput = ref(null);
|
||||
|
||||
// 响应式数据
|
||||
const showCreateDirDialog = ref(false);
|
||||
const showRenameDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
|
||||
const createDirForm = ref({
|
||||
name: ''
|
||||
});
|
||||
|
||||
const renameForm = ref({
|
||||
oldPath: '',
|
||||
oldName: '',
|
||||
newName: ''
|
||||
});
|
||||
|
||||
const editFile = ref({
|
||||
path: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 计算属性
|
||||
const currentPath = computed(() => sftpStore.currentPath);
|
||||
const files = computed(() => sftpStore.files);
|
||||
const loading = computed(() => sftpStore.loading);
|
||||
const error = computed(() => sftpStore.error);
|
||||
const selectedFiles = computed(() => sftpStore.selectedFiles);
|
||||
|
||||
// 方法
|
||||
const refreshDirectory = async () => {
|
||||
await sftpStore.listDirectory(currentPath.value);
|
||||
};
|
||||
|
||||
const goToParentDirectory = async () => {
|
||||
await sftpStore.goToParentDirectory();
|
||||
};
|
||||
|
||||
const handleFileClick = (file) => {
|
||||
sftpStore.toggleFileSelection(file.path);
|
||||
};
|
||||
|
||||
const handleFileDoubleClick = async (file) => {
|
||||
if (file.type === 'directory') {
|
||||
await sftpStore.enterDirectory(file.name);
|
||||
} else {
|
||||
// 打开文件编辑
|
||||
try {
|
||||
const content = await sftpStore.readFile(file.path);
|
||||
editFile.value = {
|
||||
path: file.path,
|
||||
content: content
|
||||
};
|
||||
showEditDialog.value = true;
|
||||
} catch (err) {
|
||||
ElMessage.error('读取文件失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileContextMenu = (event, file) => {
|
||||
// 这里可以实现右键菜单
|
||||
console.log('右键菜单:', file);
|
||||
};
|
||||
|
||||
const handleDownload = async (file) => {
|
||||
try {
|
||||
await sftpStore.downloadFile(file.path, file.name);
|
||||
ElMessage.success('下载成功');
|
||||
} catch (err) {
|
||||
ElMessage.error('下载失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除 ${file.type === 'directory' ? '目录' : '文件'} "${file.name}" 吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
await sftpStore.deleteItem(file.path, file.type);
|
||||
ElMessage.success('删除成功');
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
try {
|
||||
const count = selectedFiles.value.size;
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${count} 个项目吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
for (const filePath of selectedFiles.value) {
|
||||
const file = files.value.find(f => f.path === filePath);
|
||||
if (file) {
|
||||
await sftpStore.deleteItem(file.path, file.type);
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(`成功删除 ${count} 个项目`);
|
||||
sftpStore.clearSelection();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await sftpStore.uploadFile(file);
|
||||
ElMessage.success(`文件 ${file.name} 上传成功`);
|
||||
} catch (err) {
|
||||
ElMessage.error(`文件 ${file.name} 上传失败: ` + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空文件输入
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async () => {
|
||||
if (!createDirForm.value.name.trim()) {
|
||||
ElMessage.warning('请输入文件夹名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sftpStore.createDirectory(createDirForm.value.name.trim());
|
||||
ElMessage.success('文件夹创建成功');
|
||||
showCreateDirDialog.value = false;
|
||||
createDirForm.value.name = '';
|
||||
} catch (err) {
|
||||
ElMessage.error('创建文件夹失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameForm.value.newName.trim()) {
|
||||
ElMessage.warning('请输入新名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sftpStore.renameItem(renameForm.value.oldPath, renameForm.value.newName.trim());
|
||||
ElMessage.success('重命名成功');
|
||||
showRenameDialog.value = false;
|
||||
renameForm.value = {
|
||||
oldPath: '',
|
||||
oldName: '',
|
||||
newName: ''
|
||||
};
|
||||
} catch (err) {
|
||||
ElMessage.error('重命名失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
await sftpStore.saveFile(editFile.value.path, editFile.value.content);
|
||||
ElMessage.success('文件保存成功');
|
||||
showEditDialog.value = false;
|
||||
} catch (err) {
|
||||
ElMessage.error('保存文件失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 自动刷新当前目录
|
||||
if (sftpStore.isConnected) {
|
||||
sftpStore.listDirectory(sftpStore.currentPath);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sftp-file-manager {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-buttons .el-button {
|
||||
width: 120px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
margin-left: auto;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-list-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #303133;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background-color: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.file-item.directory {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 10px;
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-directory {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-editor:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 配置axios拦截器自动添加认证头
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
}, (error) => {
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
// 配置axios响应拦截器处理认证错误
|
||||
axios.interceptors.response.use((response) => {
|
||||
return response
|
||||
}, (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 清除本地存储的token并重定向到登录页
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/terminal/:serverId?',
|
||||
name: 'Terminal',
|
||||
component: () => import('@/views/Terminal.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/quick-connect',
|
||||
name: 'QuickConnect',
|
||||
component: () => import('@/views/QuickConnect.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(localStorage.getItem('token'))
|
||||
const isAuthenticated = ref(!!localStorage.getItem('token'))
|
||||
|
||||
// 登录
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
const { token: newToken, user: userData } = response.data
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', newToken)
|
||||
token.value = newToken
|
||||
user.value = userData
|
||||
isAuthenticated.value = true
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '登录失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
const register = async (email, phone, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register', {
|
||||
email,
|
||||
phone,
|
||||
password
|
||||
})
|
||||
|
||||
const { token: newToken, user: userData } = response.data
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', newToken)
|
||||
token.value = newToken
|
||||
user.value = userData
|
||||
isAuthenticated.value = true
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '注册失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注销
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
token.value = null
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
}
|
||||
})
|
||||
|
||||
user.value = response.data.user
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logout()
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async (currentPassword, newPassword) => {
|
||||
try {
|
||||
await axios.put('/api/auth/password', {
|
||||
currentPassword,
|
||||
newPassword
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '修改密码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化认证状态
|
||||
const initializeAuth = async () => {
|
||||
if (token.value) {
|
||||
await fetchCurrentUser()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
changePassword,
|
||||
initializeAuth
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
export const useServersStore = defineStore('servers', () => {
|
||||
const servers = ref([])
|
||||
const groups = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 获取所有服务器
|
||||
const fetchServers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/servers', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
servers.value = response.data.servers
|
||||
await fetchGroups()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '获取服务器列表失败'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取服务器分组
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/servers/groups', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
groups.value = response.data.groups
|
||||
} catch (error) {
|
||||
console.error('获取分组失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加服务器
|
||||
const addServer = async (serverData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/servers', serverData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
|
||||
await fetchServers()
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '添加服务器失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新服务器
|
||||
const updateServer = async (serverId, serverData) => {
|
||||
try {
|
||||
await axios.put(`/api/servers/${serverId}`, serverData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
|
||||
await fetchServers()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '更新服务器失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除服务器
|
||||
const deleteServer = async (serverId) => {
|
||||
try {
|
||||
await axios.delete(`/api/servers/${serverId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
|
||||
await fetchServers()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '删除服务器失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取服务器认证信息
|
||||
const getServerCredentials = async (serverId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/servers/${serverId}/credentials`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '获取认证信息失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试服务器连接(使用表单数据)
|
||||
const testConnection = async (connectionData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/servers/test-connection', connectionData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || '连接测试失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按分组获取服务器
|
||||
const getServersByGroup = (groupName) => {
|
||||
return servers.value.filter(server => server.group_name === groupName)
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
groups,
|
||||
loading,
|
||||
fetchServers,
|
||||
addServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
getServerCredentials,
|
||||
testConnection,
|
||||
getServersByGroup
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useSftpStore = defineStore('sftp', () => {
|
||||
const isConnected = ref(false);
|
||||
const currentServer = ref(null);
|
||||
const currentPath = ref('/');
|
||||
const files = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const selectedFiles = ref(new Set());
|
||||
|
||||
// 连接SFTP
|
||||
const connectSftp = async (server) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await axios.post('/api/sftp/connect', {
|
||||
serverId: server.id
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
isConnected.value = true;
|
||||
currentServer.value = server;
|
||||
currentPath.value = response.data.currentPath;
|
||||
files.value = response.data.files;
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 断开SFTP连接
|
||||
const disconnectSftp = async () => {
|
||||
try {
|
||||
if (currentServer.value) {
|
||||
await axios.post('/api/sftp/disconnect', {
|
||||
serverId: currentServer.value.id
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('断开SFTP连接错误:', err);
|
||||
} finally {
|
||||
isConnected.value = false;
|
||||
currentServer.value = null;
|
||||
currentPath.value = '/';
|
||||
files.value = [];
|
||||
selectedFiles.value.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// 列出目录内容
|
||||
const listDirectory = async (path = '.') => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await axios.get('/api/sftp/list', {
|
||||
params: {
|
||||
serverId: currentServer.value.id,
|
||||
path: path
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentPath.value = response.data.currentPath;
|
||||
files.value = response.data.files;
|
||||
selectedFiles.value.clear();
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 进入目录
|
||||
const enterDirectory = async (directoryName) => {
|
||||
const newPath = currentPath.value === '/' ?
|
||||
`/${directoryName}` :
|
||||
`${currentPath.value}/${directoryName}`;
|
||||
await listDirectory(newPath);
|
||||
};
|
||||
|
||||
// 返回上级目录
|
||||
const goToParentDirectory = async () => {
|
||||
if (currentPath.value === '/' || currentPath.value === '.') {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentPath = currentPath.value.split('/').slice(0, -1).join('/') || '/';
|
||||
await listDirectory(parentPath);
|
||||
};
|
||||
|
||||
// 读取文件内容
|
||||
const readFile = async (filePath) => {
|
||||
try {
|
||||
const response = await axios.get('/api/sftp/file', {
|
||||
params: {
|
||||
serverId: currentServer.value.id,
|
||||
path: filePath
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.content;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存文件内容
|
||||
const saveFile = async (filePath, content) => {
|
||||
try {
|
||||
const response = await axios.post('/api/sftp/file', {
|
||||
serverId: currentServer.value.id,
|
||||
path: filePath,
|
||||
content: content
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建目录
|
||||
const createDirectory = async (dirName) => {
|
||||
try {
|
||||
const newPath = currentPath.value === '/' ?
|
||||
`/${dirName}` :
|
||||
`${currentPath.value}/${dirName}`;
|
||||
|
||||
const response = await axios.post('/api/sftp/mkdir', {
|
||||
serverId: currentServer.value.id,
|
||||
path: newPath
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
await listDirectory(currentPath.value);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件或目录
|
||||
const deleteItem = async (itemPath, itemType) => {
|
||||
try {
|
||||
const response = await axios.delete('/api/sftp/delete', {
|
||||
data: {
|
||||
serverId: currentServer.value.id,
|
||||
path: itemPath,
|
||||
type: itemType
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
await listDirectory(currentPath.value);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 重命名文件或目录
|
||||
const renameItem = async (oldPath, newName) => {
|
||||
try {
|
||||
const parentPath = oldPath.split('/').slice(0, -1).join('/') || '/';
|
||||
const newPath = parentPath === '/' ?
|
||||
`/${newName}` :
|
||||
`${parentPath}/${newName}`;
|
||||
|
||||
const response = await axios.post('/api/sftp/rename', {
|
||||
serverId: currentServer.value.id,
|
||||
oldPath: oldPath,
|
||||
newPath: newPath
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
await listDirectory(currentPath.value);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
const uploadFile = async (file, targetPath = null) => {
|
||||
try {
|
||||
const uploadPath = targetPath ||
|
||||
(currentPath.value === '/' ?
|
||||
`/${file.name}` :
|
||||
`${currentPath.value}/${file.name}`);
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('serverId', currentServer.value.id);
|
||||
formData.append('path', uploadPath);
|
||||
|
||||
const response = await axios.post('/api/sftp/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
await listDirectory(currentPath.value);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (filePath, fileName) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/sftp/download?serverId=${currentServer.value.id}&path=${encodeURIComponent(filePath)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换文件选择状态
|
||||
const toggleFileSelection = (filePath) => {
|
||||
if (selectedFiles.value.has(filePath)) {
|
||||
selectedFiles.value.delete(filePath);
|
||||
} else {
|
||||
selectedFiles.value.add(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
// 清除所有选择
|
||||
const clearSelection = () => {
|
||||
selectedFiles.value.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected,
|
||||
currentServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
error,
|
||||
selectedFiles,
|
||||
|
||||
// 操作
|
||||
connectSftp,
|
||||
disconnectSftp,
|
||||
listDirectory,
|
||||
enterDirectory,
|
||||
goToParentDirectory,
|
||||
readFile,
|
||||
saveFile,
|
||||
createDirectory,
|
||||
deleteItem,
|
||||
renameItem,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
toggleFileSelection,
|
||||
clearSelection
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
export const useTerminalStore = defineStore('terminal', () => {
|
||||
const socket = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const currentConnection = ref(null)
|
||||
const terminalOutput = ref('')
|
||||
const connectionError = ref(null)
|
||||
|
||||
// 连接到Socket.IO服务器
|
||||
const connectSocket = (token) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.value = io({
|
||||
auth: {
|
||||
token
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('connect', () => {
|
||||
console.log('Socket连接成功')
|
||||
// 连接成功后立即发送认证
|
||||
socket.value.emit('authenticate', token)
|
||||
})
|
||||
|
||||
socket.value.on('authenticated', (data) => {
|
||||
if (data.success) {
|
||||
console.log('Socket认证成功')
|
||||
// 认证成功后设置Socket事件监听器
|
||||
setupSocketListeners()
|
||||
resolve() // 认证成功,解析Promise
|
||||
} else {
|
||||
console.error('Socket认证失败:', data.error)
|
||||
reject(new Error('Socket认证失败'))
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('connect_error', (error) => {
|
||||
console.error('Socket连接错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
// 设置连接超时
|
||||
setTimeout(() => {
|
||||
if (!socket.value.connected) {
|
||||
reject(new Error('Socket连接超时'))
|
||||
}
|
||||
}, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
// 监听Socket事件(在Socket连接建立后设置)
|
||||
const setupSocketListeners = () => {
|
||||
if (!socket.value || socket.value._listenersSetup) return
|
||||
|
||||
// 标记已设置监听器,避免重复设置
|
||||
socket.value._listenersSetup = true
|
||||
|
||||
socket.value.on('ssh-connected', () => {
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
connectionError.value = null
|
||||
console.log('SSH连接成功')
|
||||
})
|
||||
|
||||
socket.value.on('ssh-data', (data) => {
|
||||
// 确保数据是字符串格式
|
||||
const output = typeof data === 'string' ? data : String(data)
|
||||
terminalOutput.value += output
|
||||
|
||||
// 限制输出长度,防止内存溢出
|
||||
if (terminalOutput.value.length > 50000) {
|
||||
terminalOutput.value = terminalOutput.value.slice(-25000)
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('ssh-error', (data) => {
|
||||
isConnecting.value = false
|
||||
isConnected.value = false
|
||||
connectionError.value = data.error
|
||||
console.error('SSH连接错误:', data.error)
|
||||
})
|
||||
|
||||
socket.value.on('ssh-closed', () => {
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
currentConnection.value = null
|
||||
console.log('SSH连接已关闭')
|
||||
})
|
||||
|
||||
socket.value.on('disconnect', () => {
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
console.log('Socket连接断开')
|
||||
// 清除监听器标记
|
||||
socket.value._listenersSetup = false
|
||||
})
|
||||
}
|
||||
|
||||
// 连接到SSH服务器
|
||||
const connectToServer = async (serverId) => {
|
||||
if (!socket.value) {
|
||||
throw new Error('Socket未连接')
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
connectionError.value = null
|
||||
terminalOutput.value = ''
|
||||
currentConnection.value = { serverId, type: 'saved' }
|
||||
|
||||
socket.value.emit('connect-ssh', serverId)
|
||||
}
|
||||
|
||||
// 快速连接
|
||||
const quickConnect = (connectionInfo) => {
|
||||
if (!socket.value) {
|
||||
throw new Error('Socket未连接')
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
connectionError.value = null
|
||||
terminalOutput.value = ''
|
||||
currentConnection.value = { ...connectionInfo, type: 'quick' }
|
||||
|
||||
socket.value.emit('quick-connect', connectionInfo)
|
||||
}
|
||||
|
||||
// 发送命令到终端
|
||||
const sendCommand = (command) => {
|
||||
if (socket.value && isConnected.value) {
|
||||
socket.value.emit('send-command', command)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送输入到终端
|
||||
const sendInput = (input) => {
|
||||
if (socket.value && isConnected.value) {
|
||||
socket.value.emit('ssh-input', input)
|
||||
}
|
||||
}
|
||||
|
||||
// 调整终端大小
|
||||
const resizeTerminal = (size) => {
|
||||
if (socket.value && isConnected.value) {
|
||||
socket.value.emit('resize', size)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnect = () => {
|
||||
if (socket.value) {
|
||||
socket.value.emit('disconnect-ssh')
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
currentConnection.value = null
|
||||
terminalOutput.value = ''
|
||||
connectionError.value = null
|
||||
}
|
||||
|
||||
// 清除终端输出
|
||||
const clearOutput = () => {
|
||||
terminalOutput.value = ''
|
||||
}
|
||||
|
||||
// 断开Socket连接
|
||||
const disconnectSocket = () => {
|
||||
if (socket.value) {
|
||||
socket.value.disconnect()
|
||||
socket.value = null
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
currentConnection.value = null
|
||||
terminalOutput.value = ''
|
||||
connectionError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
currentConnection,
|
||||
terminalOutput,
|
||||
connectionError,
|
||||
connectSocket,
|
||||
connectToServer,
|
||||
quickConnect,
|
||||
sendCommand,
|
||||
sendInput,
|
||||
resizeTerminal,
|
||||
disconnect,
|
||||
clearOutput,
|
||||
disconnectSocket
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h1 class="logo">WebSSH</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<span class="user-info">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ authStore.user?.email || '用户' }}
|
||||
<el-icon class="el-icon--right"><SwitchButton /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
注销登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-container class="main-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside width="300px" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>服务器管理</h2>
|
||||
<el-button type="primary" size="small" @click="showAddServerDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加服务器
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="server-list">
|
||||
<el-tree
|
||||
:data="serverTree"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
@node-click="handleServerClick"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div class="tree-node">
|
||||
<span v-if="data.type === 'group'" class="group-node">
|
||||
<el-icon><Folder /></el-icon>
|
||||
{{ data.label }} ({{ data.children.length }})
|
||||
</span>
|
||||
<span v-else class="server-node">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span class="server-name" @click="handleServerClick(data)">{{ data.label }}</span>
|
||||
<span class="server-info">{{ data.host }}:{{ data.port }}</span>
|
||||
<el-dropdown trigger="click" @command="handleServerAction(data.serverId, $event)" @click.stop>
|
||||
<el-button class="server-actions" size="small" text @click.stop>
|
||||
<el-icon><More /></el-icon>
|
||||
<span class="action-text">操作</span>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑服务器
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" style="color: #f56c6c;">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除服务器
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<div class="quick-connect">
|
||||
<el-button type="success" @click="$router.push('/quick-connect')" style="width: 100%">
|
||||
<el-icon><Connection /></el-icon>
|
||||
快速连接
|
||||
</el-button>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<el-main class="main-content">
|
||||
<div class="welcome-panel">
|
||||
<h1>欢迎使用 WebSSH</h1>
|
||||
<p>选择左侧的服务器开始连接,或使用快速连接功能</p>
|
||||
|
||||
<div class="features">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="feature-card">
|
||||
<el-icon size="48" color="#409EFF"><Monitor /></el-icon>
|
||||
<h3>SSH 终端</h3>
|
||||
<p>支持标准SSH协议,提供完整的终端体验</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="feature-card">
|
||||
<el-icon size="48" color="#67C23A"><Lock /></el-icon>
|
||||
<h3>安全加密</h3>
|
||||
<p>AES-256加密存储,保障服务器信息安全</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="feature-card">
|
||||
<el-icon size="48" color="#E6A23C"><Folder /></el-icon>
|
||||
<h3>分组管理</h3>
|
||||
<p>支持服务器分组,方便管理和快速访问</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 添加服务器对话框 -->
|
||||
<el-dialog v-model="showAddServerDialog" title="添加服务器" width="500px">
|
||||
<ServerForm @success="handleServerAdded" @cancel="showAddServerDialog = false" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑服务器对话框 -->
|
||||
<el-dialog v-model="showEditServerDialog" :title="'编辑服务器 - ' + editingServerName" width="500px">
|
||||
<ServerForm
|
||||
v-if="editingServerId"
|
||||
:server-id="editingServerId"
|
||||
@success="handleServerUpdated"
|
||||
@cancel="showEditServerDialog = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Folder, Monitor, Connection, Lock, More, Edit, Delete, User, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { useServersStore } from '@/stores/servers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ServerForm from '@/components/ServerForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const serversStore = useServersStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const showAddServerDialog = ref(false)
|
||||
const showEditServerDialog = ref(false)
|
||||
const editingServerId = ref(null)
|
||||
const editingServerName = ref('')
|
||||
|
||||
// 计算服务器树形数据
|
||||
const serverTree = computed(() => {
|
||||
const groups = {}
|
||||
|
||||
// 按分组组织服务器
|
||||
serversStore.servers.forEach(server => {
|
||||
const groupName = server.group_name || 'Default'
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = []
|
||||
}
|
||||
|
||||
groups[groupName].push({
|
||||
id: `server-${server.id}`,
|
||||
label: server.name,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
type: 'server',
|
||||
serverId: server.id
|
||||
})
|
||||
})
|
||||
|
||||
// 转换为树形结构
|
||||
return Object.keys(groups).map(groupName => ({
|
||||
id: `group-${groupName}`,
|
||||
label: groupName,
|
||||
type: 'group',
|
||||
children: groups[groupName]
|
||||
}))
|
||||
})
|
||||
|
||||
// 处理服务器点击
|
||||
const handleServerClick = (data) => {
|
||||
if (data.type === 'server') {
|
||||
router.push(`/terminal/${data.serverId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理服务器添加成功
|
||||
const handleServerAdded = () => {
|
||||
showAddServerDialog.value = false
|
||||
ElMessage.success('服务器添加成功')
|
||||
}
|
||||
|
||||
// 处理服务器更新成功
|
||||
const handleServerUpdated = () => {
|
||||
showEditServerDialog.value = false
|
||||
editingServerId.value = null
|
||||
editingServerName.value = ''
|
||||
ElMessage.success('服务器更新成功')
|
||||
}
|
||||
|
||||
// 处理服务器操作
|
||||
const handleServerAction = async (serverId, command) => {
|
||||
const server = serversStore.servers.find(s => s.id === serverId)
|
||||
if (!server) return
|
||||
|
||||
if (command === 'edit') {
|
||||
editingServerId.value = serverId
|
||||
editingServerName.value = server.name
|
||||
showEditServerDialog.value = true
|
||||
} else if (command === 'delete') {
|
||||
await handleDeleteServer(serverId, server.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除服务器
|
||||
const handleDeleteServer = async (serverId, serverName) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除服务器 "${serverName}" 吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const result = await serversStore.deleteServer(serverId)
|
||||
if (result.success) {
|
||||
ElMessage.success('服务器删除成功')
|
||||
} else {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户操作
|
||||
const handleUserCommand = async (command) => {
|
||||
if (command === 'logout') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要注销登录吗?',
|
||||
'注销确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
authStore.logout()
|
||||
ElMessage.success('注销成功')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('注销失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await serversStore.fetchServers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left .logo {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.user-info .el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: white;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.group-node {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.server-node {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.server-node:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
flex: 1;
|
||||
margin: 0 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.server-actions {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
color: #606266;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-actions:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-connect {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 40px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.welcome-panel {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-panel h1 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.welcome-panel p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin: 16px 0 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-form">
|
||||
<div class="login-header">
|
||||
<h1>WebSSH</h1>
|
||||
<p>在线SSH终端工具</p>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="loginForm" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="邮箱地址"
|
||||
size="large"
|
||||
prefix-icon="Message"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>还没有账号?</span>
|
||||
<el-link type="primary" @click="$router.push('/register')">立即注册</el-link>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginForm = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value) return
|
||||
|
||||
try {
|
||||
const valid = await loginForm.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.login(form.email, form.password)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} else {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('登录失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-footer span {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="quick-connect-page">
|
||||
<el-container class="quick-connect-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<el-header class="quick-connect-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="$router.back()" size="small">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<span class="page-title">快速连接</span>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<el-main class="quick-connect-main">
|
||||
<div class="connect-form-container">
|
||||
<el-card class="connect-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>SSH连接信息</h3>
|
||||
<p>输入服务器信息进行一次性连接(不保存)</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="connectForm" label-width="100px">
|
||||
<el-form-item label="主机地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="请输入IP地址或域名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入SSH用户名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="认证方式" prop="authType">
|
||||
<el-radio-group v-model="form.authType">
|
||||
<el-radio label="password">密码认证</el-radio>
|
||||
<el-radio label="key">私钥认证</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.authType === 'password'" label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入SSH密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.authType === 'key'" label="私钥" prop="privateKey">
|
||||
<el-input
|
||||
v-model="form.privateKey"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入SSH私钥内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="connecting"
|
||||
@click="handleConnect"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
连接
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 连接状态提示 -->
|
||||
<div v-if="connectionError" class="connection-error">
|
||||
<el-alert
|
||||
:title="connectionError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="connecting" class="connecting-status">
|
||||
<el-alert
|
||||
title="正在连接..."
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 快速连接示例 -->
|
||||
<el-card class="examples-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>连接示例</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="examples">
|
||||
<div class="example-item" @click="fillExample('ubuntu')">
|
||||
<h4>Ubuntu服务器</h4>
|
||||
<p>主机: 192.168.1.100</p>
|
||||
<p>端口: 22</p>
|
||||
<p>用户: ubuntu</p>
|
||||
</div>
|
||||
|
||||
<div class="example-item" @click="fillExample('centos')">
|
||||
<h4>CentOS服务器</h4>
|
||||
<p>主机: 192.168.1.101</p>
|
||||
<p>端口: 22</p>
|
||||
<p>用户: root</p>
|
||||
</div>
|
||||
|
||||
<div class="example-item" @click="fillExample('docker')">
|
||||
<h4>Docker容器</h4>
|
||||
<p>主机: localhost</p>
|
||||
<p>端口: 2222</p>
|
||||
<p>用户: root</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Connection } from '@element-plus/icons-vue'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const terminalStore = useTerminalStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const connectForm = ref()
|
||||
const connecting = ref(false)
|
||||
const connectionError = ref('')
|
||||
|
||||
const form = reactive({
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
host: [
|
||||
{ required: true, message: '请输入主机地址', trigger: 'blur' }
|
||||
],
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: 'blur',
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.authType === 'password' && !value) {
|
||||
callback(new Error('请输入密码'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
privateKey: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入私钥',
|
||||
trigger: 'blur',
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.authType === 'key' && !value) {
|
||||
callback(new Error('请输入私钥'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 处理连接
|
||||
const handleConnect = async () => {
|
||||
if (!connectForm.value) return
|
||||
|
||||
try {
|
||||
const valid = await connectForm.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
if (!authStore.token) {
|
||||
ElMessage.error('请先登录')
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化Socket连接
|
||||
if (!terminalStore.socket) {
|
||||
terminalStore.connectSocket(authStore.token)
|
||||
}
|
||||
|
||||
connecting.value = true
|
||||
connectionError.value = ''
|
||||
|
||||
const connectionInfo = {
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username
|
||||
}
|
||||
|
||||
if (form.authType === 'password') {
|
||||
connectionInfo.password = form.password
|
||||
} else {
|
||||
connectionInfo.privateKey = form.privateKey
|
||||
}
|
||||
|
||||
// 执行快速连接
|
||||
terminalStore.quickConnect(connectionInfo)
|
||||
|
||||
// 监听连接状态
|
||||
const checkConnection = setInterval(() => {
|
||||
if (terminalStore.isConnected) {
|
||||
clearInterval(checkConnection)
|
||||
connecting.value = false
|
||||
ElMessage.success('连接成功')
|
||||
// 跳转到终端页面
|
||||
router.push('/terminal')
|
||||
} else if (terminalStore.connectionError) {
|
||||
clearInterval(checkConnection)
|
||||
connecting.value = false
|
||||
connectionError.value = terminalStore.connectionError
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 超时检查
|
||||
setTimeout(() => {
|
||||
if (connecting.value) {
|
||||
clearInterval(checkConnection)
|
||||
connecting.value = false
|
||||
connectionError.value = '连接超时,请检查网络和服务器状态'
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
} catch (error) {
|
||||
connecting.value = false
|
||||
ElMessage.error('连接失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 填充示例数据
|
||||
const fillExample = (type) => {
|
||||
const examples = {
|
||||
ubuntu: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'ubuntu',
|
||||
authType: 'password',
|
||||
password: ''
|
||||
},
|
||||
centos: {
|
||||
host: '192.168.1.101',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: ''
|
||||
},
|
||||
docker: {
|
||||
host: 'localhost',
|
||||
port: 2222,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(form, examples[type])
|
||||
ElMessage.info(`已填充${type}示例配置,请填写密码后连接`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 组件挂载时的初始化
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quick-connect-page {
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-connect-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-connect-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quick-connect-main {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connect-form-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.connect-card,
|
||||
.examples-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.connection-error,
|
||||
.connecting-status {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.examples {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.example-item {
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.example-item:hover {
|
||||
border-color: #409eff;
|
||||
background-color: #f5f7fa;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.example-item h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.example-item p {
|
||||
margin: 4px 0;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.quick-connect-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.examples {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-form">
|
||||
<div class="register-header">
|
||||
<h1>注册账号</h1>
|
||||
<p>创建您的WebSSH账户</p>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="registerForm" @submit.prevent="handleRegister">
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="邮箱地址"
|
||||
size="large"
|
||||
prefix-icon="Message"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="手机号码(可选)"
|
||||
size="large"
|
||||
prefix-icon="Phone"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
style="width: 100%"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="register-footer">
|
||||
<span>已有账号?</span>
|
||||
<el-link type="primary" @click="$router.push('/login')">立即登录</el-link>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const registerForm = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validatePassword = (rule, value, callback) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== form.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: validatePassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!registerForm.value) return
|
||||
|
||||
try {
|
||||
const valid = await registerForm.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.register(form.email, form.phone, form.password)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/')
|
||||
} else {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('注册失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.register-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.register-footer span {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Generated
+372
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"name": "webssh-xshell",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webssh-xshell",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-8.2.2.tgz",
|
||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "webssh-xshell",
|
||||
"version": "1.0.0",
|
||||
"description": "Web-based Xshell clone with user authentication",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"build": "npm run build:frontend",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"start": "cd backend && npm start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
现在想要开发一个web版本的xshell工具,功能模仿xshell,但是要集成注册和登录
|
||||
|
||||
|
||||
一、 用户与权限系统 (User Center)
|
||||
这是最基础的门槛,确保数据隔离。
|
||||
|
||||
P0 - 注册与登录
|
||||
|
||||
用户注册(邮箱/手机号 + 密码)。
|
||||
|
||||
用户登录(JWT 或 Session 鉴权)。
|
||||
|
||||
密码找回/重置。
|
||||
|
||||
二、 资产管理 (Asset Management)
|
||||
模仿 Xshell 左侧的“会话管理器”。
|
||||
|
||||
P0 - 服务器(会话)增删改查
|
||||
|
||||
添加服务器:主机名 (Name)、IP、端口 (Port)、用户名 (User)。
|
||||
|
||||
认证方式选择:密码认证、私钥 (PEM/PPK) 认证。
|
||||
|
||||
核心安全需求:所有密码和私钥在数据库中必须加密存储(使用 AES-256,密钥与用户 ID 绑定),绝不能明文存储。
|
||||
|
||||
P1 - 分组管理 (Tree View)
|
||||
|
||||
支持文件夹层级结构(类似 Xshell 左侧树状图)。
|
||||
|
||||
支持拖拽排序、归类。
|
||||
|
||||
P1 - 快速连接
|
||||
|
||||
不保存会话,直接输入 ssh user@ip 进行一次性连接。
|
||||
|
||||
三、 核心终端功能 (Terminal Core)
|
||||
这是模仿 Xshell 体验的重中之重,前端通常使用 xterm.js,后端使用 SSH2 库。
|
||||
|
||||
P0 - SSH 连接与交互
|
||||
|
||||
基于 WebSocket 实现全双工通信。
|
||||
|
||||
支持标准的 Shell 操作(输入命令、回显、Ctrl+C 中断等)。
|
||||
|
||||
支持 Vim/Top 等全屏应用的正确渲染。
|
||||
|
||||
P0 - 终端自适应 (Resize)
|
||||
|
||||
浏览器窗口大小改变时,自动同步发送 window-change 信号给后端,防止 Vim 错位。
|
||||
|
||||
P1 - 复制粘贴
|
||||
|
||||
选中文本自动复制(模仿 Xshell 行为)。
|
||||
|
||||
右键菜单粘贴,或 Shift+Insert 粘贴。
|
||||
|
||||
P1 - 多标签页 (Tabs)
|
||||
|
||||
支持同时打开多个 SSH 会话,顶部 Tab 切换。
|
||||
|
||||
支持分屏显示(Split View,左右或上下分屏)。
|
||||
|
||||
P2 - 外观定制
|
||||
|
||||
字号调整、字体设置(如 Fira Code)。
|
||||
|
||||
配色方案切换(黑客绿、Solarized、Monokai 等)。
|
||||
|
||||
四、 文件传输 (SFTP)
|
||||
Xshell 通常配合 Xftp 使用,Web 版需要集成轻量级文件管理。
|
||||
|
||||
P1 - 侧边栏文件树
|
||||
|
||||
在终端侧边展示当前目录的文件列表。
|
||||
|
||||
支持双击进入目录、返回上级。
|
||||
|
||||
P1 - 上传/下载
|
||||
|
||||
上传:支持拖拽本地文件到浏览器窗口,自动上传到当前目录。
|
||||
|
||||
下载:右键文件 -> 下载。
|
||||
|
||||
P2 - 在线编辑
|
||||
|
||||
双击文本文件,弹窗进行代码编辑,保存后自动上传覆盖(通过 SFTP 协议)。
|
||||
|
||||
五、 高级辅助功能 (Advanced Features)
|
||||
这是让你的工具“像 Xshell”的关键。
|
||||
|
||||
P1 - 保持连接 (Keep-Alive)
|
||||
|
||||
发送心跳包,防止防火墙或 Nginx 切断 WebSocket 连接。
|
||||
|
||||
断线自动重连机制。
|
||||
|
||||
P2 - 快捷命令 (Snippets)
|
||||
|
||||
侧边栏或底部栏预设常用命令(如 tail -f logs, docker ps)。
|
||||
|
||||
点击按钮直接发送命令到终端。
|
||||
|
||||
P2 - 脚本录制与回放
|
||||
|
||||
记录操作日志(Asciinema 格式),用于审计。
|
||||
|
||||
推荐技术架构
|
||||
为了实现上述需求,建议的技术选型如下:
|
||||
|
||||
前端 (Frontend)
|
||||
|
||||
框架: Vue3。
|
||||
|
||||
终端组件:xterm.js (业界标准,VS Code 也在用)。
|
||||
|
||||
UI 库:Ant Design 或 Element Plus (用于布局和表单)。
|
||||
|
||||
后端 (Backend)
|
||||
|
||||
语言: Node.js (生态好,SSH 库丰富)。
|
||||
|
||||
SSH 库:
|
||||
|
||||
Go: golang.org/x/crypto/ssh
|
||||
|
||||
Node.js: ssh2
|
||||
|
||||
WebSocket:
|
||||
|
||||
Node.js: socket.io 或 ws
|
||||
|
||||
Web 框架: NestJS/Express (Node)。
|
||||
|
||||
数据库
|
||||
|
||||
MySQL:存储用户信息、资产信息。
|
||||
|
||||
Redis:存储临时的 WebSocket Session 状态。
|
||||
|
||||
|
||||
============================================
|
||||
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
方法2:使用在线工具生成
|
||||
|
||||
访问 https://www.random.org/strings/
|
||||
生成32个字符的随机字符串(字母和数字组合)
|
||||
方法3:手动创建32字节字符串
|
||||
|
||||
使用任何32个字符的组合,例如:
|
||||
k3yF0rW3bSSH2024Pr0j3ctS3cur1ty!
|
||||
MyS3cur3K3yF0rW3bSSHPr0j3ct2024
|
||||
Reference in new issue
Block a user