First commit

This commit is contained in:
dalaolala committed 2026-02-10 13:29:45 +08:00
commit ce1b546a6c
39 files changed
+10654

No files matched your search

+1
View File
@@ -0,0 +1 @@
node_modules
+338
View File
@@ -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
- 发送邮件
---
**注意**: 本项目仍在开发中,部分功能可能不完善。生产环境使用前请充分测试。
+16
View File
@@ -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
+16
View File
@@ -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
+31
View File
@@ -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();
+97
View File
@@ -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
};
+33
View File
@@ -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
};
+1930
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -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"
}
}
+196
View File
@@ -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;
+378
View File
@@ -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;
+181
View File
@@ -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;
+431
View File
@@ -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;
+61
View File
@@ -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}`);
});
+262
View File
@@ -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;
+107
View File
@@ -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
};
+107
View File
@@ -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();
+215
View File
@@ -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;
+13
View File
@@ -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>
+1481
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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"
}
}
+59
View File
@@ -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>
+343
View File
@@ -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>
+518
View File
@@ -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>
+45
View File
@@ -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')
+56
View File
@@ -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
+125
View File
@@ -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
}
})
+157
View File
@@ -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
}
})
+315
View File
@@ -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
};
});
+200
View File
@@ -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
}
})
+474
View File
@@ -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>
+146
View File
@@ -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>
+401
View File
@@ -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>
+182
View File
@@ -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
+26
View File
@@ -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
}
}
}
})
+372
View File
@@ -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"
}
}
}
}
+16
View File
@@ -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"
}
}
+152
View File
@@ -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