update
This commit is contained in:
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="nexus-terminal" uuid="e7b75293-7bc6-44e2-bde1-518a65febbc1">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:D:\OneDrive\文档\GitHub\nexus-terminal\packages\data\nexus-terminal.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<libraries>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="4">
|
||||
<item index="0" class="java.lang.String" itemvalue="Werkzeug" />
|
||||
<item index="1" class="java.lang.String" itemvalue="requests" />
|
||||
<item index="2" class="java.lang.String" itemvalue="Flask" />
|
||||
<item index="3" class="java.lang.String" itemvalue="Telethon" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="$USER_HOME$/AppData/Local/Programs/Python/Python313/python.exe" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/nexus-terminal.iml" filepath="$PROJECT_DIR$/.idea/nexus-terminal.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+4703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nexus-terminal",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Heavrnl/nexus-terminal.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Heavrnl/nexus-terminal/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
|
||||
"description": ""
|
||||
}
|
||||
Generated
+3307
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npx ts-node-dev --respawn --transpile-only src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"connect-sqlite3": "^0.9.15",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ssh2": "^1.16.0",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/connect-sqlite3": "^0.9.5",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDb } from '../database';
|
||||
|
||||
const db = getDb(); // 获取数据库实例
|
||||
|
||||
// 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中)
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password: string; // 数据库中存储的哈希密码
|
||||
// 其他可能的字段...
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户登录请求 (POST /api/v1/auth/login)
|
||||
*/
|
||||
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body; // 从请求体获取用户名和密码
|
||||
|
||||
// 基础输入验证
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ message: '用户名和密码不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据用户名查询用户
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
// 从 users 表中选择需要的字段
|
||||
db.get('SELECT id, username, hashed_password FROM users WHERE username = ?', [username], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error('查询用户时出错:', err.message);
|
||||
// 返回通用错误信息,避免泄露数据库细节
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row); // 如果找到用户,则 resolve 用户对象;否则 resolve undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 如果未找到用户
|
||||
if (!user) {
|
||||
console.log(`登录尝试失败: 用户未找到 - ${username}`);
|
||||
// 返回 401 未授权状态码和通用错误信息
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 比较用户提交的密码和数据库中存储的哈希密码
|
||||
const isMatch = await bcrypt.compare(password, user.hashed_password);
|
||||
|
||||
// 如果密码不匹配
|
||||
if (!isMatch) {
|
||||
console.log(`登录尝试失败: 密码错误 - ${username}`);
|
||||
// 返回 401 未授权状态码和通用错误信息
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 认证成功 ---
|
||||
console.log(`登录成功: ${username}`);
|
||||
|
||||
// 在 session 中存储用户信息
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
|
||||
// 返回成功响应 (可以包含一些非敏感的用户信息)
|
||||
res.status(200).json({
|
||||
message: '登录成功。',
|
||||
user: { id: user.id, username: user.username } // 不返回密码哈希
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// 捕获数据库查询或其他异步操作中的错误
|
||||
console.error('登录时出错:', error);
|
||||
res.status(500).json({ message: '登录过程中发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
// 其他认证相关函数的占位符 (登出, 管理员设置等)
|
||||
// export const logout = ...
|
||||
// export const setupAdmin = ...
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* 认证中间件:检查用户是否已登录 (通过 session 中的 userId 判断)
|
||||
*/
|
||||
export const isAuthenticated = (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.session && req.session.userId) {
|
||||
// 用户已登录,继续处理请求
|
||||
next();
|
||||
} else {
|
||||
// 用户未登录,返回 401 未授权错误
|
||||
res.status(401).json({ message: '未授权:请先登录。' });
|
||||
}
|
||||
};
|
||||
|
||||
// 未来可以添加基于角色的授权中间件等
|
||||
// export const isAdmin = ...
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { login } from './auth.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/v1/auth/login - 用户登录接口
|
||||
router.post('/login', login);
|
||||
|
||||
// 未来可以添加的其他认证相关路由
|
||||
// router.post('/logout', logout); // 登出
|
||||
// router.get('/status', getStatus); // 获取当前登录状态
|
||||
// router.post('/setup', setupAdmin); // 用于首次创建管理员账号的接口
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Statement } from 'sqlite3'; // 引入 Statement 类型
|
||||
import { getDb } from '../database';
|
||||
import { encrypt } from '../utils/crypto'; // 引入加密函数
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// 连接数据结构 (仅用于类型提示,不包含敏感信息)
|
||||
interface ConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password'; // MVP 仅支持密码
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新连接 (POST /api/v1/connections)
|
||||
*/
|
||||
export const createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name, host, port = 22, username, password } = req.body;
|
||||
const auth_method = 'password'; // MVP 强制为 password
|
||||
const userId = req.session.userId; // 从会话获取用户 ID
|
||||
|
||||
// 输入验证 (基础)
|
||||
if (!name || !host || !username || !password) {
|
||||
res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, password)。' });
|
||||
return;
|
||||
}
|
||||
if (typeof port !== 'number' || port <= 0 || port > 65535) {
|
||||
res.status(400).json({ message: '端口号无效。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 加密密码
|
||||
const encryptedPassword = encrypt(password);
|
||||
const now = Math.floor(Date.now() / 1000); // 当前 Unix 时间戳 (秒)
|
||||
|
||||
// 插入数据库
|
||||
const result = await new Promise<{ lastID: number }>((resolve, reject) => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
// 注意:这里没有存储 userId,因为 MVP 只有一个用户。如果未来支持多用户,需要添加 user_id 字段。
|
||||
// 使用 function 关键字以保留正确的 this 上下文,并为 err 和 this 添加类型注解
|
||||
stmt.run(name, host, port, username, auth_method, encryptedPassword, now, now, function (this: Statement, err: Error | null) {
|
||||
if (err) {
|
||||
console.error('插入连接时出错:', err.message);
|
||||
return reject(new Error('创建连接失败'));
|
||||
}
|
||||
// this.lastID 包含新插入行的 ID
|
||||
// 使用类型断言 (as any) 来解决 TS 类型检查问题
|
||||
resolve({ lastID: (this as any).lastID });
|
||||
});
|
||||
stmt.finalize(); // 完成语句执行
|
||||
});
|
||||
|
||||
// 返回成功响应
|
||||
res.status(201).json({
|
||||
message: '连接创建成功。',
|
||||
connection: {
|
||||
id: result.lastID,
|
||||
name, host, port, username, auth_method,
|
||||
created_at: now, updated_at: now, last_connected_at: null
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建连接时发生错误:', error);
|
||||
res.status(500).json({ message: '创建连接时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取连接列表 (GET /api/v1/connections)
|
||||
*/
|
||||
export const getConnections = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId; // 虽然 MVP 只有一个用户,但保留以备将来使用
|
||||
|
||||
try {
|
||||
// 查询数据库,排除敏感字段 encrypted_password
|
||||
// 注意:如果未来支持多用户,需要添加 WHERE user_id = ? 条件
|
||||
const connections = await new Promise<ConnectionInfo[]>((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at
|
||||
FROM connections
|
||||
ORDER BY name ASC`, // 按名称排序
|
||||
(err, rows: ConnectionInfo[]) => {
|
||||
if (err) {
|
||||
console.error('查询连接列表时出错:', err.message);
|
||||
return reject(new Error('获取连接列表失败'));
|
||||
}
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
res.status(200).json(connections);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取连接列表时发生错误:', error);
|
||||
res.status(500).json({ message: '获取连接列表时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
// 其他控制器函数的占位符
|
||||
// export const getConnectionById = ...
|
||||
// export const updateConnection = ...
|
||||
// export const deleteConnection = ...
|
||||
// export const testConnection = ...
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
|
||||
import { createConnection, getConnections } from './connections.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用认证中间件到所有 /connections 路由
|
||||
router.use(isAuthenticated); // 恢复认证检查
|
||||
|
||||
// GET /api/v1/connections - 获取连接列表
|
||||
router.get('/', getConnections);
|
||||
|
||||
// POST /api/v1/connections - 创建新连接
|
||||
router.post('/', createConnection);
|
||||
|
||||
// 未来可以添加其他路由,如获取单个连接、更新、删除、测试连接等
|
||||
// router.get('/:id', getConnectionById);
|
||||
// router.put('/:id', updateConnection);
|
||||
// router.delete('/:id', deleteConnection);
|
||||
// router.post('/:id/test', testConnection);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 数据库文件路径 (相对于 backend 项目根目录)
|
||||
const dbDir = path.resolve(__dirname, '../../data'); // 使用 '../../data' 定位到 monorepo 根目录下的 data 文件夹
|
||||
const dbPath = path.join(dbDir, 'nexus-terminal.db');
|
||||
|
||||
// 确保数据库目录存在
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
console.log(`数据库目录已创建: ${dbDir}`);
|
||||
}
|
||||
|
||||
// 使用详细模式以获得更丰富的错误信息
|
||||
const verboseSqlite3 = sqlite3.verbose();
|
||||
|
||||
// 创建并连接数据库
|
||||
// 使用单例 (singleton) 模式确保只有一个数据库连接实例
|
||||
let dbInstance: sqlite3.Database | null = null;
|
||||
|
||||
export const getDb = (): sqlite3.Database => {
|
||||
if (!dbInstance) {
|
||||
dbInstance = new verboseSqlite3.Database(dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('打开数据库时出错:', err.message);
|
||||
// 在实际应用中,这里可能需要更健壮的错误处理,例如直接退出进程
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`已连接到 SQLite 数据库: ${dbPath}`);
|
||||
// 可选:启用外键约束 (如果数据库设计中使用了外键)
|
||||
// dbInstance.run('PRAGMA foreign_keys = ON;', (pragmaErr) => {
|
||||
// if (pragmaErr) {
|
||||
// console.error('启用外键约束失败:', pragmaErr.message);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
});
|
||||
}
|
||||
return dbInstance;
|
||||
};
|
||||
|
||||
// 优雅停机:在应用接收到中断信号 (如 Ctrl+C) 时关闭数据库连接
|
||||
process.on('SIGINT', () => {
|
||||
if (dbInstance) {
|
||||
dbInstance.close((err) => {
|
||||
if (err) {
|
||||
console.error('关闭数据库时出错:', err.message);
|
||||
} else {
|
||||
console.log('数据库连接已关闭。');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
export default getDb;
|
||||
@@ -0,0 +1,157 @@
|
||||
import express = require('express');
|
||||
// import express = require('express'); // 移除重复导入
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express'; // 添加 RequestHandler
|
||||
import http from 'http'; // 引入 http 模块
|
||||
import session from 'express-session';
|
||||
import connectSqlite3 from 'connect-sqlite3';
|
||||
import path from 'path'; // 需要 path 模块
|
||||
import bcrypt from 'bcrypt'; // 引入 bcrypt 用于哈希密码
|
||||
import { getDb } from './database';
|
||||
import { runMigrations } from './migrations';
|
||||
import authRouter from './auth/auth.routes'; // 导入认证路由
|
||||
import connectionsRouter from './connections/connections.routes'; // 导入连接路由
|
||||
import sftpRouter from './sftp/sftp.routes'; // 导入 SFTP 路由
|
||||
import { initializeWebSocket } from './websocket'; // 导入 WebSocket 初始化函数
|
||||
|
||||
// 基础 Express 应用设置 (后续会扩展)
|
||||
const app = express();
|
||||
const server = http.createServer(app); // 创建 HTTP 服务器实例
|
||||
|
||||
// --- 会话存储设置 ---
|
||||
const SQLiteStore = connectSqlite3(session);
|
||||
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
|
||||
|
||||
// --- 中间件 ---
|
||||
app.use(express.json()); // 添加此行以解析 JSON 请求体
|
||||
|
||||
// 会话中间件配置
|
||||
// TODO: 将 secret 移到环境变量中,不要硬编码在代码里!
|
||||
const sessionSecret = process.env.SESSION_SECRET || 'a-very-insecure-secret-for-dev';
|
||||
if (sessionSecret === 'a-very-insecure-secret-for-dev') {
|
||||
console.warn('警告:正在使用默认的不安全会话密钥,请在生产环境中设置 SESSION_SECRET 环境变量!');
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
// 使用类型断言 (as any) 来解决 @types/connect-sqlite3 和 @types/express-session 的类型冲突
|
||||
store: new SQLiteStore({
|
||||
db: 'nexus-terminal.db', // 数据库文件名
|
||||
dir: dbPath, // 数据库文件所在目录
|
||||
table: 'sessions' // 存储会话的表名 (会自动创建)
|
||||
}) as any,
|
||||
secret: sessionSecret,
|
||||
resave: false, // 强制保存 session 即使它没有变化 (通常为 false)
|
||||
saveUninitialized: false, // 强制将未初始化的 session 存储 (通常为 false)
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // Cookie 有效期:7天 (毫秒)
|
||||
httpOnly: true, // 防止客户端脚本访问 cookie
|
||||
secure: process.env.NODE_ENV === 'production' // 仅在 HTTPS 下发送 cookie (生产环境)
|
||||
}
|
||||
}));
|
||||
// 将 session 中间件保存到一个变量,以便传递给 WebSocket 初始化函数
|
||||
const sessionMiddleware = session({
|
||||
// 使用类型断言 (as any) 来解决 @types/connect-sqlite3 和 @types/express-session 的类型冲突
|
||||
store: new SQLiteStore({
|
||||
db: 'nexus-terminal.db', // 数据库文件名
|
||||
dir: dbPath, // 数据库文件所在目录
|
||||
table: 'sessions' // 存储会话的表名 (会自动创建)
|
||||
}) as any,
|
||||
secret: sessionSecret,
|
||||
resave: false, // 强制保存 session 即使它没有变化 (通常为 false)
|
||||
saveUninitialized: false, // 强制将未初始化的 session 存储 (通常为 false)
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // Cookie 有效期:7天 (毫秒)
|
||||
httpOnly: true, // 防止客户端脚本访问 cookie
|
||||
secure: process.env.NODE_ENV === 'production' // 仅在 HTTPS 下发送 cookie (生产环境)
|
||||
}
|
||||
});
|
||||
app.use(sessionMiddleware); // 应用会话中间件
|
||||
|
||||
|
||||
// 扩展 Express Request 类型以包含 session 数据 (如果需要更明确的类型提示)
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: number; // 存储登录用户的 ID
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const port = process.env.PORT || 3001; // 示例端口,可配置
|
||||
|
||||
// --- API 路由 ---
|
||||
app.use('/api/v1/auth', authRouter); // 挂载认证相关的路由
|
||||
app.use('/api/v1/connections', connectionsRouter); // 挂载连接相关的路由
|
||||
app.use('/api/v1/sftp', sftpRouter); // 挂载 SFTP 相关的路由
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
res.json({ status: '后端服务运行中!' }); // 响应也改为中文
|
||||
});
|
||||
|
||||
// 在服务器启动前初始化数据库并执行迁移
|
||||
const initializeDatabase = async () => {
|
||||
try {
|
||||
const db = getDb(); // 获取数据库实例 (同时会建立连接)
|
||||
await runMigrations(db); // 执行数据库迁移 (创建表)
|
||||
// console.log('数据库迁移执行成功。'); // 日志已移至 migrations.ts
|
||||
|
||||
// 检查管理员用户是否存在
|
||||
const userCount = await new Promise<number>((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM users', (err, row: { count: number }) => { // 查询用户数量
|
||||
if (err) {
|
||||
console.error('检查 users 表时出错:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
resolve(row.count);
|
||||
});
|
||||
});
|
||||
|
||||
if (userCount === 0) {
|
||||
console.warn('------------------------------------------------------');
|
||||
console.warn('警告: 数据库中未找到任何用户。正在创建默认管理员...');
|
||||
// 创建默认管理员
|
||||
const defaultAdminUsername = 'admin';
|
||||
const defaultAdminPassword = 'adminpassword'; // 仅用于首次创建
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(defaultAdminPassword, saltRounds);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await new Promise<void>((resolveInsert, rejectInsert) => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO users (username, hashed_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(defaultAdminUsername, hashedPassword, now, now, function (err: Error | null) {
|
||||
if (err) {
|
||||
console.error('创建默认管理员时出错:', err.message);
|
||||
return rejectInsert(new Error('创建默认管理员失败'));
|
||||
}
|
||||
console.log(`默认管理员 '${defaultAdminUsername}' (密码: '${defaultAdminPassword}') 已创建。请尽快修改密码!`);
|
||||
resolveInsert();
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
console.warn('------------------------------------------------------');
|
||||
|
||||
} else {
|
||||
console.log(`数据库中找到 ${userCount} 个用户。`);
|
||||
}
|
||||
|
||||
console.log('数据库初始化检查完成。');
|
||||
} catch (error) {
|
||||
console.error('数据库初始化失败:', error);
|
||||
process.exit(1); // 如果数据库初始化失败,则退出进程
|
||||
}
|
||||
};
|
||||
|
||||
// 启动 HTTP 服务器 (而不是直接 app.listen)
|
||||
const startServer = () => {
|
||||
server.listen(port, () => { // 使用 server.listen
|
||||
console.log(`后端服务器正在监听 http://localhost:${port}`);
|
||||
// 初始化 WebSocket 服务器,并传入 HTTP 服务器实例和会话解析器
|
||||
initializeWebSocket(server, sessionMiddleware as RequestHandler);
|
||||
});
|
||||
};
|
||||
|
||||
// 先执行数据库初始化,成功后再启动服务器
|
||||
initializeDatabase().then(startServer);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Database } from 'sqlite3';
|
||||
import { getDb } from './database';
|
||||
|
||||
const createUsersTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
two_factor_secret TEXT NULL, -- 2FA 密钥占位符
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// MVP (最小可行产品) 阶段: 只包含基础字段,支持密码认证,暂不考虑代理和标签
|
||||
const createConnectionsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password')), -- MVP 阶段仅支持密码认证
|
||||
encrypted_password TEXT NULL, -- 加密存储的密码占位符 (加密逻辑在应用层实现)
|
||||
-- encrypted_private_key TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
|
||||
-- encrypted_passphrase TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
|
||||
-- proxy_id INTEGER NULL, -- MVP 阶段跳过代理相关字段
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_connected_at INTEGER NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// 未来可能需要的其他表 (根据项目文档)
|
||||
// const createProxiesTableSQL = \`...\`; // 代理表
|
||||
// const createTagsTableSQL = \`...\`; // 标签表
|
||||
// const createConnectionTagsTableSQL = \`...\`; // 连接与标签的关联表
|
||||
// const createSettingsTableSQL = \`...\`; // 设置表
|
||||
// const createAuditLogsTableSQL = \`...\`; // 审计日志表
|
||||
// const createApiKeysTableSQL = \`...\`; // API 密钥表
|
||||
|
||||
/**
|
||||
* 执行数据库迁移 (创建表)
|
||||
* @param db - 数据库实例
|
||||
* @returns Promise,在所有迁移完成后 resolve
|
||||
*/
|
||||
export const runMigrations = (db: Database): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(createUsersTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('创建 users 表时出错:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Users 表已检查/创建。');
|
||||
});
|
||||
|
||||
db.run(createConnectionsTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('创建 connections 表时出错:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connections 表已检查/创建。');
|
||||
resolve(); // 所有表创建完成后 resolve Promise
|
||||
});
|
||||
|
||||
// 如果未来添加了更多表,在此处继续链式调用 db.run(...)
|
||||
// db.run(createProxiesTableSQL, callback);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 允许通过命令行直接运行此文件来执行迁移 (例如: node dist/migrations.js)
|
||||
if (require.main === module) {
|
||||
const db = getDb();
|
||||
runMigrations(db)
|
||||
.then(() => {
|
||||
console.log('数据库迁移执行成功。');
|
||||
// 如果是独立运行,可以选择关闭数据库连接,但在应用启动流程中通常不需要
|
||||
// db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('数据库迁移执行失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path'; // 需要 path 用于处理文件名
|
||||
import { activeSshConnections } from '../websocket'; // 导入共享的连接 Map
|
||||
|
||||
/**
|
||||
* 处理文件下载请求 (GET /api/v1/sftp/download)
|
||||
*/
|
||||
export const downloadFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const connectionId = req.query.connectionId as string; // 从查询参数获取
|
||||
const remotePath = req.query.remotePath as string; // 从查询参数获取
|
||||
|
||||
// 参数验证
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: '未授权:需要登录。' });
|
||||
return;
|
||||
}
|
||||
if (!connectionId || !remotePath) {
|
||||
res.status(400).json({ message: '缺少必要的查询参数 (connectionId, remotePath)。' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`SFTP 下载请求:用户 ${userId}, 连接 ${connectionId}, 路径 ${remotePath}`);
|
||||
|
||||
// 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话
|
||||
let userSftpSession = null;
|
||||
// 注意:这种查找方式效率不高,实际应用中可能需要更优化的结构来按 userId 查找连接
|
||||
for (const [ws, connData] of activeSshConnections.entries()) {
|
||||
// 假设 AuthenticatedWebSocket 上存储了 userId
|
||||
if ((ws as any).userId === userId && connData.sftp) {
|
||||
// 这里简单地取第一个找到的匹配连接,没有处理 connectionId 的匹配
|
||||
// TODO: 需要一种方式将 HTTP 请求与特定的 WebSocket/SSH/SFTP 会话关联起来
|
||||
// 临时方案:假设一个用户只有一个活动的 SSH/SFTP 会话
|
||||
userSftpSession = connData.sftp;
|
||||
console.log(`找到用户 ${userId} 的活动 SFTP 会话。`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userSftpSession) {
|
||||
console.warn(`SFTP 下载失败:未找到用户 ${userId} 的活动 SFTP 会话。`);
|
||||
res.status(404).json({ message: '未找到活动的 SFTP 会话。请确保您已通过 WebSocket 连接到目标服务器。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取文件状态以确定文件大小(可选,但有助于设置 Content-Length)
|
||||
const stats = await new Promise<import('ssh2').Stats>((resolve, reject) => {
|
||||
userSftpSession!.lstat(remotePath, (err, stats) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stats);
|
||||
});
|
||||
});
|
||||
|
||||
if (!stats.isFile()) {
|
||||
res.status(400).json({ message: '指定的路径不是一个文件。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(remotePath)}"`); // 建议浏览器下载的文件名
|
||||
res.setHeader('Content-Type', 'application/octet-stream'); // 通用二进制类型
|
||||
if (stats.size) {
|
||||
res.setHeader('Content-Length', stats.size.toString());
|
||||
}
|
||||
|
||||
// 创建可读流并 pipe 到响应对象
|
||||
const readStream = userSftpSession.createReadStream(remotePath);
|
||||
|
||||
readStream.on('error', (err: Error) => { // 添加 Error 类型注解
|
||||
console.error(`SFTP 读取流错误 (用户 ${userId}, 路径 ${remotePath}):`, err);
|
||||
// 如果响应头还没发送,可以发送错误状态码
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ message: `读取远程文件失败: ${err.message}` });
|
||||
} else {
|
||||
// 如果头已发送,只能尝试结束响应
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
readStream.pipe(res); // 将文件流直接传输给客户端
|
||||
|
||||
// 监听响应对象的 close 事件,确保流被正确关闭 (虽然 pipe 通常会处理)
|
||||
res.on('close', () => {
|
||||
console.log(`SFTP 下载流关闭 (用户 ${userId}, 路径 ${remotePath})`);
|
||||
// readStream.destroy(); // 可选:显式销毁流
|
||||
});
|
||||
|
||||
console.log(`SFTP 开始下载 (用户 ${userId}, 路径 ${remotePath})`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`SFTP 下载处理失败 (用户 ${userId}, 路径 ${remotePath}):`, error);
|
||||
if (!res.headersSent) {
|
||||
if (error.message?.includes('No such file')) {
|
||||
res.status(404).json({ message: '远程文件未找到。' });
|
||||
} else {
|
||||
res.status(500).json({ message: `处理下载请求时出错: ${error.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 其他 SFTP 控制器函数 (例如上传)
|
||||
// export const uploadFile = ...
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import { downloadFile } from './sftp.controller'; // 稍后创建
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用认证中间件
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// GET /api/v1/sftp/download?connectionId=...&remotePath=...
|
||||
router.get('/download', downloadFile);
|
||||
|
||||
// 未来可以添加其他 SFTP 相关 REST API (如果需要,例如上传的大文件断点续传初始化)
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,78 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
// 从环境变量获取加密密钥,提供一个不安全的默认值用于开发
|
||||
// 警告:生产环境中必须设置一个强随机的 32 字节密钥 (例如通过 openssl rand -base64 32 生成)
|
||||
const encryptionKeyEnv = process.env.ENCRYPTION_KEY;
|
||||
if (!encryptionKeyEnv && process.env.NODE_ENV === 'production') {
|
||||
console.error('错误:生产环境中必须设置 ENCRYPTION_KEY 环境变量!');
|
||||
process.exit(1);
|
||||
}
|
||||
// 使用一个 32 字节的字符串作为不安全的开发默认值
|
||||
const defaultDevKey = '12345678901234567890123456789012';
|
||||
const encryptionKey = Buffer.from(
|
||||
encryptionKeyEnv || defaultDevKey,
|
||||
'utf8' // 或者 'base64' 如果环境变量是 base64 编码的
|
||||
); // Buffer.from utf8 string of 32 chars is 32 bytes
|
||||
|
||||
// 重新检查,虽然 Buffer.from 应该保证了长度,但以防万一
|
||||
if (encryptionKey.length !== 32) {
|
||||
console.error(`错误:加密密钥长度必须是 32 字节,当前长度为 ${encryptionKey.length}。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!encryptionKeyEnv) { // 仅在未设置环境变量时显示警告
|
||||
console.warn('警告:正在使用默认的不安全加密密钥,请在生产环境中设置 ENCRYPTION_KEY 环境变量!');
|
||||
}
|
||||
|
||||
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const ivLength = 16; // GCM 推荐的 IV 长度为 12 或 16 字节
|
||||
const tagLength = 16; // GCM 认证标签长度
|
||||
|
||||
/**
|
||||
* 加密文本 (例如连接密码)
|
||||
* @param text - 需要加密的明文
|
||||
* @returns Base64 编码的字符串,格式为 "iv:encrypted:tag"
|
||||
*/
|
||||
export const encrypt = (text: string): string => {
|
||||
try {
|
||||
const iv = crypto.randomBytes(ivLength);
|
||||
const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// 将 iv、密文和认证标签组合并编码
|
||||
return Buffer.concat([iv, encrypted, tag]).toString('base64');
|
||||
} catch (error) {
|
||||
console.error('加密失败:', error);
|
||||
throw new Error('加密过程中发生错误');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解密文本
|
||||
* @param encryptedText - Base64 编码的加密字符串 ("iv:encrypted:tag")
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
export const decrypt = (encryptedText: string): string => {
|
||||
try {
|
||||
const data = Buffer.from(encryptedText, 'base64');
|
||||
if (data.length < ivLength + tagLength) {
|
||||
throw new Error('无效的加密数据格式');
|
||||
}
|
||||
|
||||
// 从组合数据中提取 iv、密文和认证标签
|
||||
const iv = data.slice(0, ivLength);
|
||||
const encrypted = data.slice(ivLength, data.length - tagLength);
|
||||
const tag = data.slice(data.length - tagLength);
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
|
||||
decipher.setAuthTag(tag); // 设置认证标签以供验证
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error);
|
||||
// 在实际应用中,解密失败通常意味着数据被篡改或密钥错误
|
||||
// 不应向客户端泄露具体错误细节
|
||||
throw new Error('解密过程中发生错误或数据无效');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,771 @@
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import { Request, RequestHandler } from 'express';
|
||||
import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats
|
||||
import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用)
|
||||
import { getDb } from './database'; // 引入数据库实例
|
||||
import { decrypt } from './utils/crypto'; // 引入解密函数
|
||||
import path from 'path'; // 需要 path
|
||||
|
||||
// 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息
|
||||
interface AuthenticatedWebSocket extends WebSocket {
|
||||
isAlive?: boolean;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
sshClient?: Client; // 关联的 SSH Client 实例
|
||||
sshShellStream?: ClientChannel; // 关联的 SSH Shell Stream
|
||||
sftpStream?: SFTPWrapper; // 关联的 SFTP Stream
|
||||
}
|
||||
|
||||
// 存储活跃的 SSH/SFTP 连接 (导出以便其他模块访问)
|
||||
export const activeSshConnections = new Map<AuthenticatedWebSocket, { client: Client, shell: ClientChannel, sftp?: SFTPWrapper }>();
|
||||
|
||||
// 存储正在进行的 SFTP 上传操作 (key: uploadId, value: WriteStream)
|
||||
// 注意:WriteStream 类型来自 'fs',但 ssh2 的流行为类似
|
||||
const activeUploads = new Map<string, WriteStream>();
|
||||
|
||||
// 数据库连接信息接口 (包含加密密码)
|
||||
interface DbConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password';
|
||||
encrypted_password?: string; // 注意是可选的,因为可能没有密码 (虽然 MVP 要求有)
|
||||
// 其他字段...
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理指定 WebSocket 连接关联的 SSH 资源
|
||||
* @param ws - WebSocket 连接实例
|
||||
*/
|
||||
const cleanupSshConnection = (ws: AuthenticatedWebSocket) => {
|
||||
const connection = activeSshConnections.get(ws);
|
||||
if (connection) {
|
||||
console.log(`WebSocket: 清理用户 ${ws.username} 的 SSH/SFTP 连接...`);
|
||||
// 注意:SFTP 流通常不需要显式关闭,它依赖于 SSH Client 的关闭
|
||||
// connection.sftp?.end(); // SFTPWrapper 没有 end 方法
|
||||
connection.shell?.end(); // 尝试结束 shell 流
|
||||
connection.client?.end(); // 结束 SSH 客户端连接会隐式关闭 SFTP
|
||||
activeSshConnections.delete(ws); // 从 Map 中移除
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const initializeWebSocket = (server: http.Server, sessionParser: RequestHandler): WebSocketServer => {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const db = getDb(); // 获取数据库实例
|
||||
|
||||
const interval = setInterval(() => {
|
||||
wss.clients.forEach((ws: WebSocket) => {
|
||||
const extWs = ws as AuthenticatedWebSocket;
|
||||
if (extWs.isAlive === false) {
|
||||
console.log(`WebSocket 心跳检测:用户 ${extWs.username} 连接无响应,正在终止...`);
|
||||
cleanupSshConnection(extWs); // 清理 SSH 资源
|
||||
return extWs.terminate();
|
||||
}
|
||||
extWs.isAlive = false;
|
||||
extWs.ping(() => {});
|
||||
});
|
||||
}, 60000); // Increased interval to 60 seconds
|
||||
|
||||
server.on('upgrade', (request: Request, socket, head) => {
|
||||
// @ts-ignore
|
||||
sessionParser(request, {} as any, () => {
|
||||
if (!request.session || !request.session.userId) {
|
||||
console.log('WebSocket 认证失败:未找到会话或用户未登录。');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
console.log(`WebSocket 认证成功:用户 ${request.session.username} (ID: ${request.session.userId})`);
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
const extWs = ws as AuthenticatedWebSocket;
|
||||
extWs.userId = request.session.userId;
|
||||
extWs.username = request.session.username;
|
||||
wss.emit('connection', extWs, request);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => {
|
||||
ws.isAlive = true;
|
||||
console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 已连接。`);
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
console.log(`WebSocket:收到来自 ${ws.username} 的消息: ${message.toString().substring(0, 100)}...`); // 截断长消息日志
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.toString());
|
||||
const connection = activeSshConnections.get(ws); // 获取当前连接信息
|
||||
const sftp = connection?.sftp; // 获取 SFTP 实例
|
||||
|
||||
// 辅助函数发送错误消息
|
||||
const sendSftpError = (action: string, path: string | undefined, error: any, customMsg?: string) => {
|
||||
const errorMessage = customMsg || (error instanceof Error ? error.message : String(error));
|
||||
console.error(`SFTP: 用户 ${ws.username} 执行 ${action} 操作 ${path ? `于 ${path}` : ''} 失败:`, error);
|
||||
ws.send(JSON.stringify({ type: `sftp:${action}:error`, path, payload: `${action} 失败: ${errorMessage}` }));
|
||||
};
|
||||
|
||||
// 辅助函数发送成功消息
|
||||
const sendSftpSuccess = (action: string, path: string | undefined, payload?: any) => {
|
||||
console.log(`SFTP: 用户 ${ws.username} 执行 ${action} 操作 ${path ? `于 ${path}` : ''} 成功。`);
|
||||
ws.send(JSON.stringify({ type: `sftp:${action}:success`, path, payload }));
|
||||
};
|
||||
|
||||
// 检查 SFTP 会话是否存在
|
||||
const ensureSftp = (action: string, path?: string): SFTPWrapper | null => {
|
||||
if (!sftp) {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP ${action} 请求,但无活动 SFTP 会话。`);
|
||||
ws.send(JSON.stringify({ type: `sftp:${action}:error`, path, payload: 'SFTP 会话未初始化或已断开。' }));
|
||||
return null;
|
||||
}
|
||||
return sftp;
|
||||
};
|
||||
|
||||
|
||||
switch (parsedMessage.type) {
|
||||
// --- 处理 SSH 连接请求 ---
|
||||
case 'ssh:connect': {
|
||||
// 注意:ssh:connect 内部逻辑需要自行处理 sftp 实例的获取,不能依赖顶层的 sftp 变量
|
||||
if (activeSshConnections.has(ws)) {
|
||||
console.warn(`WebSocket: 用户 ${ws.username} 已有活动的 SSH 连接,忽略新的连接请求。`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '已存在活动的 SSH 连接。' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = parsedMessage.payload?.connectionId;
|
||||
if (!connectionId) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '缺少 connectionId。' }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`WebSocket: 用户 ${ws.username} 请求连接到 ID: ${connectionId}`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' }));
|
||||
|
||||
// 1. 从数据库获取连接信息
|
||||
const connInfo = await new Promise<DbConnectionInfo | null>((resolve, reject) => {
|
||||
// 注意:如果多用户,需要验证 connectionId 是否属于当前 userId
|
||||
db.get('SELECT * FROM connections WHERE id = ?', [connectionId], (err, row: DbConnectionInfo) => {
|
||||
if (err) return reject(new Error('查询连接信息失败'));
|
||||
resolve(row ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
if (!connInfo) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `未找到 ID 为 ${connectionId} 的连接配置。` }));
|
||||
return;
|
||||
}
|
||||
if (!connInfo.encrypted_password) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '连接配置缺少密码信息。' }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` }));
|
||||
|
||||
// 2. 解密密码
|
||||
let password = '';
|
||||
try {
|
||||
password = decrypt(connInfo.encrypted_password);
|
||||
} catch (decryptError: any) {
|
||||
console.error(`解密连接 ${connectionId} 密码失败:`, decryptError);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '无法解密连接凭证。' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 建立 SSH 连接
|
||||
const sshClient = new Client();
|
||||
ws.sshClient = sshClient; // 关联 client
|
||||
|
||||
sshClient.on('ready', () => {
|
||||
console.log(`SSH: 用户 ${ws.username} 到 ${connInfo.host} 连接成功!`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' }));
|
||||
|
||||
// 4. 请求 Shell 通道
|
||||
sshClient.shell((err, stream) => {
|
||||
if (err) {
|
||||
console.error(`SSH: 用户 ${ws.username} 打开 Shell 失败:`, err);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${err.message}` }));
|
||||
cleanupSshConnection(ws);
|
||||
return;
|
||||
}
|
||||
ws.sshShellStream = stream; // 关联 stream
|
||||
// 存储活动连接 (此时 sftp 可能还未就绪)
|
||||
activeSshConnections.set(ws, { client: sshClient, shell: stream });
|
||||
console.log(`SSH: 用户 ${ws.username} Shell 通道已打开。`);
|
||||
|
||||
// 尝试初始化 SFTP 会话
|
||||
sshClient.sftp((sftpErr, sftp) => {
|
||||
if (sftpErr) {
|
||||
console.error(`SFTP: 用户 ${ws.username} 初始化失败:`, sftpErr);
|
||||
// 即使 SFTP 失败,也保持 Shell 连接,但发送错误通知
|
||||
ws.send(JSON.stringify({ type: 'sftp:error', payload: `SFTP 初始化失败: ${sftpErr.message}` }));
|
||||
// 不再发送 ssh:connected,因为 SFTP 也是核心功能的一部分
|
||||
// ws.send(JSON.stringify({ type: 'ssh:connected' }));
|
||||
// 可以在这里发送一个包含错误的状态
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'Shell 已连接,但 SFTP 初始化失败。' }));
|
||||
return;
|
||||
}
|
||||
console.log(`SFTP: 用户 ${ws.username} 会话已初始化。`);
|
||||
// 将 SFTP 实例存入 Map
|
||||
const existingConn = activeSshConnections.get(ws);
|
||||
if (existingConn) {
|
||||
existingConn.sftp = sftp;
|
||||
}
|
||||
// SFTP 就绪后,才真正通知前端连接完成
|
||||
ws.send(JSON.stringify({ type: 'ssh:connected' }));
|
||||
});
|
||||
|
||||
|
||||
// 5. 数据转发:Shell -> WebSocket (发送 Base64 编码的数据)
|
||||
stream.on('data', (data: Buffer) => {
|
||||
// console.log('SSH Output Buffer Length:', data.length); // Debug log
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ssh:output',
|
||||
payload: data.toString('base64'), // 将 Buffer 转为 Base64 字符串
|
||||
encoding: 'base64' // 明确告知前端编码方式
|
||||
}));
|
||||
});
|
||||
|
||||
// 6. 处理 Shell 关闭
|
||||
stream.on('close', () => {
|
||||
console.log(`SSH: 用户 ${ws.username} Shell 通道已关闭。`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' }));
|
||||
cleanupSshConnection(ws); // 清理资源
|
||||
});
|
||||
// Stderr 也使用 Base64 发送
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`); // 日志中尝试 utf8 解码预览
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ssh:output', // 同样使用 ssh:output 类型
|
||||
payload: data.toString('base64'),
|
||||
encoding: 'base64'
|
||||
}));
|
||||
});
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
console.error(`SSH: 用户 ${ws.username} 连接错误:`, err);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` }));
|
||||
cleanupSshConnection(ws);
|
||||
}).on('close', () => {
|
||||
console.log(`SSH: 用户 ${ws.username} 连接已关闭。`);
|
||||
// 确保即使 shell 没关闭,也要通知前端并清理
|
||||
if (activeSshConnections.has(ws)) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' }));
|
||||
cleanupSshConnection(ws);
|
||||
}
|
||||
}).connect({
|
||||
host: connInfo.host,
|
||||
port: connInfo.port,
|
||||
username: connInfo.username,
|
||||
password: password, // 使用解密后的密码
|
||||
// TODO: 添加对密钥认证的支持
|
||||
// privateKey: require('fs').readFileSync('/path/to/key'),
|
||||
// passphrase: 'key passphrase'
|
||||
keepaliveInterval: 30000, // Send keep-alive every 30 seconds (milliseconds)
|
||||
keepaliveCountMax: 3, // Disconnect after 3 missed keep-alives
|
||||
readyTimeout: 20000 // 连接超时时间 (毫秒)
|
||||
});
|
||||
break;
|
||||
} // end case 'ssh:connect'
|
||||
|
||||
// --- 处理 SSH 输入 ---
|
||||
case 'ssh:input': {
|
||||
const connection = activeSshConnections.get(ws);
|
||||
if (connection?.shell && parsedMessage.payload?.data) {
|
||||
connection.shell.write(parsedMessage.payload.data);
|
||||
} else {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SSH 输入,但无活动 Shell 或数据为空。`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理终端大小调整 ---
|
||||
case 'ssh:resize': {
|
||||
const connection = activeSshConnections.get(ws);
|
||||
const { cols, rows } = parsedMessage.payload || {};
|
||||
if (connection?.shell && cols && rows) {
|
||||
console.log(`SSH: 用户 ${ws.username} 调整终端大小: ${cols}x${rows}`);
|
||||
connection.shell.setWindow(rows, cols, 0, 0); // 注意参数顺序 rows, cols
|
||||
} else {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} 的调整大小请求,但无活动 Shell 或尺寸数据无效。`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理 SFTP 目录列表请求 ---
|
||||
case 'sftp:readdir': {
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('readdir', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('readdir', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求读取目录: ${targetPath}`);
|
||||
currentSftp.readdir(targetPath, (err, list) => {
|
||||
if (err) {
|
||||
sendSftpError('readdir', targetPath, err);
|
||||
return;
|
||||
}
|
||||
// 格式化文件列表以便前端使用
|
||||
const formattedList = list.map(item => ({
|
||||
filename: item.filename,
|
||||
longname: item.longname,
|
||||
attrs: {
|
||||
size: item.attrs.size,
|
||||
uid: item.attrs.uid,
|
||||
gid: item.attrs.gid,
|
||||
mode: item.attrs.mode,
|
||||
atime: item.attrs.atime * 1000,
|
||||
mtime: item.attrs.mtime * 1000,
|
||||
isDirectory: item.attrs.isDirectory(),
|
||||
isFile: item.attrs.isFile(),
|
||||
isSymbolicLink: item.attrs.isSymbolicLink(),
|
||||
}
|
||||
}));
|
||||
sendSftpSuccess('readdir', targetPath, formattedList);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理 SFTP 文件/目录状态获取请求 ---
|
||||
case 'sftp:stat': {
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('stat', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('stat', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求获取状态: ${targetPath}`);
|
||||
currentSftp.lstat(targetPath, (err, stats) => { // 使用 lstat 获取链接本身信息
|
||||
if (err) {
|
||||
sendSftpError('stat', targetPath, err);
|
||||
return;
|
||||
}
|
||||
const formattedStats = {
|
||||
mode: stats.mode,
|
||||
uid: stats.uid,
|
||||
gid: stats.gid,
|
||||
size: stats.size,
|
||||
atime: stats.atime * 1000,
|
||||
mtime: stats.mtime * 1000,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
isBlockDevice: stats.isBlockDevice(),
|
||||
isCharacterDevice: stats.isCharacterDevice(),
|
||||
isSymbolicLink: stats.isSymbolicLink(),
|
||||
isFIFO: stats.isFIFO(),
|
||||
isSocket: stats.isSocket(),
|
||||
};
|
||||
sendSftpSuccess('stat', targetPath, formattedStats);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理 SFTP 文件上传 ---
|
||||
case 'sftp:upload:start': {
|
||||
const { remotePath, uploadId, size } = parsedMessage.payload || {};
|
||||
const currentSftp = ensureSftp('upload:start', remotePath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof remotePath !== 'string' || !uploadId) {
|
||||
sendSftpError('upload:start', remotePath, '无效的上传请求参数 (remotePath, uploadId)。', undefined);
|
||||
break;
|
||||
}
|
||||
if (activeUploads.has(uploadId)) {
|
||||
sendSftpError('upload:start', remotePath, '具有相同 ID 的上传已在进行中。', undefined);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 开始上传到 ${remotePath} (ID: ${uploadId}, 大小: ${size ?? '未知'})`);
|
||||
try {
|
||||
const writeStream = currentSftp.createWriteStream(remotePath);
|
||||
writeStream.on('error', (err: Error) => {
|
||||
sendSftpError('upload', remotePath, err, `写入远程文件失败: ${err.message}`);
|
||||
activeUploads.delete(uploadId);
|
||||
});
|
||||
let uploadFinished = false;
|
||||
const onStreamEnd = (eventName: string) => {
|
||||
if (uploadFinished) return;
|
||||
uploadFinished = true;
|
||||
sendSftpSuccess('upload', remotePath, { uploadId }); // 成功时也带上 uploadId
|
||||
activeUploads.delete(uploadId);
|
||||
};
|
||||
writeStream.on('finish', () => onStreamEnd('finish'));
|
||||
writeStream.on('close', () => onStreamEnd('close'));
|
||||
activeUploads.set(uploadId, writeStream as any);
|
||||
ws.send(JSON.stringify({ type: 'sftp:upload:ready', uploadId }));
|
||||
} catch (err: any) {
|
||||
sendSftpError('upload:start', remotePath, err, `无法创建远程文件: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:upload:chunk': {
|
||||
const { uploadId, data, isLast } = parsedMessage.payload || {};
|
||||
const writeStream = activeUploads.get(uploadId);
|
||||
|
||||
if (!writeStream) {
|
||||
// console.warn(`WebSocket: 收到上传数据块 (ID: ${uploadId}),但未找到对应的上传任务。`);
|
||||
// 不必每次都报错,前端可能已经取消或完成
|
||||
break;
|
||||
}
|
||||
if (typeof data !== 'string') {
|
||||
sendSftpError('upload:chunk', undefined, '无效的数据块格式。', undefined);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
const canWriteMore = writeStream.write(buffer);
|
||||
if (!canWriteMore) {
|
||||
writeStream.once('drain', () => {
|
||||
ws.send(JSON.stringify({ type: 'sftp:upload:resume', uploadId }));
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'sftp:upload:pause', uploadId }));
|
||||
}
|
||||
if (isLast) {
|
||||
writeStream.end();
|
||||
}
|
||||
} catch (err: any) {
|
||||
sendSftpError('upload:chunk', undefined, err, `处理数据块失败: ${err.message}`);
|
||||
writeStream.end();
|
||||
activeUploads.delete(uploadId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:upload:cancel': {
|
||||
const { uploadId } = parsedMessage.payload || {};
|
||||
const writeStream = activeUploads.get(uploadId);
|
||||
if (writeStream) {
|
||||
console.log(`SFTP: 用户 ${ws.username} 取消上传 (ID: ${uploadId})`);
|
||||
writeStream.end(); // 触发清理
|
||||
// TODO: 删除部分文件? sftp.unlink?
|
||||
ws.send(JSON.stringify({ type: 'sftp:upload:cancelled', uploadId }));
|
||||
} else {
|
||||
// console.warn(`WebSocket: 收到取消上传请求 (ID: ${uploadId}),但未找到对应的上传任务。`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理 SFTP 文件读取请求 ---
|
||||
case 'sftp:readfile': {
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('readfile', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('readfile', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求读取文件: ${targetPath}`);
|
||||
const readStream = currentSftp.createReadStream(targetPath);
|
||||
let fileContent = '';
|
||||
let hasError = false;
|
||||
|
||||
readStream.on('data', (chunk: Buffer) => {
|
||||
// 尝试多种编码解码,优先 UTF-8
|
||||
try {
|
||||
fileContent += chunk.toString('utf8');
|
||||
} catch (e) {
|
||||
// 如果 UTF-8 失败,尝试其他常见编码,例如 GBK (适用于中文 Windows)
|
||||
// 注意:这只是一个尝试,可能不准确。更可靠的方法是让用户指定编码。
|
||||
try {
|
||||
// 需要安装 iconv-lite: npm install iconv-lite @types/iconv-lite -w packages/backend
|
||||
// import * as iconv from 'iconv-lite';
|
||||
// fileContent += iconv.decode(chunk, 'gbk');
|
||||
// 暂时回退到 base64 发送原始数据,让前端处理解码
|
||||
console.warn(`SFTP: 文件 ${targetPath} 无法以 UTF-8 解码,将发送 Base64 编码内容。`);
|
||||
fileContent = Buffer.concat([Buffer.from(fileContent), chunk]).toString('base64');
|
||||
} catch (decodeError) {
|
||||
console.error(`SFTP: 文件 ${targetPath} 解码失败:`, decodeError);
|
||||
sendSftpError('readfile', targetPath, '文件解码失败。');
|
||||
readStream.destroy(); // 停止读取
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
readStream.on('error', (err: Error) => {
|
||||
if (hasError) return; // 避免重复发送错误
|
||||
sendSftpError('readfile', targetPath, err);
|
||||
hasError = true;
|
||||
});
|
||||
|
||||
readStream.on('end', () => {
|
||||
if (hasError) return; // 如果之前已出错,则不发送成功消息
|
||||
// 判断是发送文本内容还是 Base64
|
||||
let payload: { content: string; encoding: 'utf8' | 'base64' };
|
||||
try {
|
||||
// 尝试再次解码整个内容为 UTF-8,如果成功则发送 UTF-8
|
||||
Buffer.from(fileContent, 'base64').toString('utf8');
|
||||
// 如果上一步是 base64 编码,这里会是原始 base64 字符串
|
||||
if (fileContent === Buffer.from(fileContent, 'base64').toString('base64')) {
|
||||
payload = { content: fileContent, encoding: 'base64' };
|
||||
} else {
|
||||
payload = { content: fileContent, encoding: 'utf8' };
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// 如果整体解码失败,则发送 Base64
|
||||
payload = { content: Buffer.from(fileContent).toString('base64'), encoding: 'base64' };
|
||||
}
|
||||
// 限制发送内容的大小,避免 WebSocket 拥塞 (例如 1MB)
|
||||
const MAX_CONTENT_SIZE = 1 * 1024 * 1024;
|
||||
if (Buffer.byteLength(payload.content, payload.encoding === 'base64' ? 'base64' : 'utf8') > MAX_CONTENT_SIZE) {
|
||||
sendSftpError('readfile', targetPath, `文件过大 (超过 ${MAX_CONTENT_SIZE / 1024 / 1024}MB),无法在编辑器中打开。`);
|
||||
} else {
|
||||
sendSftpSuccess('readfile', targetPath, payload);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 处理 SFTP 文件写入请求 ---
|
||||
case 'sftp:writefile': {
|
||||
const { path: targetPath, content, encoding } = parsedMessage.payload || {};
|
||||
const currentSftp = ensureSftp('writefile', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string' || typeof content !== 'string' || (encoding !== 'utf8' && encoding !== 'base64')) {
|
||||
sendSftpError('writefile', targetPath, '请求参数无效 (需要 path, content, encoding[\'utf8\'|\'base64\'])。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求写入文件: ${targetPath}, Encoding: ${encoding}, Content length: ${content.length}`); // 增加日志细节
|
||||
|
||||
try {
|
||||
console.log(`[writefile] Attempting to create buffer for ${targetPath}`);
|
||||
const buffer = Buffer.from(content, encoding); // 根据 encoding 解码/转换内容为 Buffer
|
||||
console.log(`[writefile] Buffer created successfully for ${targetPath}. Attempting to create write stream.`);
|
||||
const writeStream = currentSftp.createWriteStream(targetPath);
|
||||
console.log(`[writefile] Write stream created for ${targetPath}. Attaching listeners.`);
|
||||
let hasError = false;
|
||||
let operationCompleted = false; // Flag to track if finish/error occurred
|
||||
let backendTimeoutId: NodeJS.Timeout | null = null;
|
||||
const streamId = Math.random().toString(36).substring(2, 9); // Unique ID for logging this stream instance
|
||||
const BACKEND_WRITE_TIMEOUT = 15000; // 15 seconds backend timeout
|
||||
|
||||
console.log(`[${streamId}] SFTP: Attaching listeners for writeStream to ${targetPath}`);
|
||||
|
||||
const cleanupTimeout = () => {
|
||||
if (backendTimeoutId) {
|
||||
clearTimeout(backendTimeoutId);
|
||||
backendTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
writeStream.on('error', (err: Error) => {
|
||||
console.error(`[${streamId}] SFTP: writeStream 'error' event for ${targetPath}:`, err);
|
||||
if (operationCompleted) return; // Already completed
|
||||
operationCompleted = true;
|
||||
cleanupTimeout();
|
||||
sendSftpError('writefile', targetPath, err, `写入远程文件失败: ${err.message}`);
|
||||
hasError = true; // Keep track for close handler if needed
|
||||
});
|
||||
|
||||
writeStream.on('finish', () => { // 'finish' 表示所有数据已刷入底层系统
|
||||
console.log(`[${streamId}] SFTP: writeStream 'finish' event for ${targetPath}. HasError: ${hasError}`);
|
||||
if (operationCompleted) return; // Already completed (e.g., error occurred first)
|
||||
operationCompleted = true;
|
||||
cleanupTimeout();
|
||||
if (hasError) return; // Error occurred before finish
|
||||
sendSftpSuccess('writefile', targetPath);
|
||||
});
|
||||
writeStream.on('close', () => { // 'close' 表示流已关闭
|
||||
console.log(`[${streamId}] SFTP: writeStream 'close' event for ${targetPath}. writableFinished: ${writeStream.writableFinished}, HasError: ${hasError}, OperationCompleted: ${operationCompleted}`);
|
||||
cleanupTimeout(); // Clear timeout if close happens before it fires
|
||||
// If the stream closed and no error/finish/timeout event handled it yet,
|
||||
// consider it a success. This handles cases where 'finish' might not fire reliably,
|
||||
// even if writableFinished is false when close is emitted prematurely.
|
||||
if (!operationCompleted) {
|
||||
console.warn(`[${streamId}] SFTP: writeStream 'close' event occurred before 'finish' or 'error'. Assuming success for ${targetPath}.`);
|
||||
sendSftpSuccess('writefile', targetPath);
|
||||
operationCompleted = true; // Mark as completed via close
|
||||
}
|
||||
// If an error or finish occurred, the respective handlers already sent the message.
|
||||
// If finish occurred, the 'finish' handler sent success.
|
||||
// If closed without finishing and without error, the backend timeout might handle it,
|
||||
// or it might be a legitimate early close after an error on the server side not reported via 'error' event.
|
||||
});
|
||||
|
||||
// 写入数据并结束流
|
||||
console.log(`[${streamId}] SFTP: Calling writeStream.end() for ${targetPath}`);
|
||||
writeStream.end(buffer, () => {
|
||||
console.log(`[${streamId}] SFTP: writeStream.end() callback fired for ${targetPath}. Starting backend timeout.`);
|
||||
// Start backend timeout *after* end() callback fires (or immediately if no callback needed)
|
||||
backendTimeoutId = setTimeout(() => {
|
||||
if (!operationCompleted) {
|
||||
console.error(`[${streamId}] SFTP: Backend write timeout (${BACKEND_WRITE_TIMEOUT}ms) reached for ${targetPath}.`);
|
||||
operationCompleted = true; // Mark as completed due to timeout
|
||||
sendSftpError('writefile', targetPath, `后端写入超时 (${BACKEND_WRITE_TIMEOUT / 1000}秒)`);
|
||||
}
|
||||
}, BACKEND_WRITE_TIMEOUT);
|
||||
});
|
||||
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[writefile] Error during write stream creation or buffer processing for ${targetPath}:`, err); // 增加 catch 日志
|
||||
// Buffer.from 可能因无效编码或内容抛出错误
|
||||
sendSftpError('writefile', targetPath, err, `处理文件内容或创建写入流失败: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// --- 新增 SFTP 操作 ---
|
||||
case 'sftp:mkdir': {
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('mkdir', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('mkdir', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求创建目录: ${targetPath}`);
|
||||
// TODO: 考虑添加 mode 参数支持
|
||||
currentSftp.mkdir(targetPath, (err) => {
|
||||
if (err) {
|
||||
sendSftpError('mkdir', targetPath, err);
|
||||
} else {
|
||||
sendSftpSuccess('mkdir', targetPath);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:rmdir': {
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('rmdir', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('rmdir', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求删除目录: ${targetPath}`);
|
||||
currentSftp.rmdir(targetPath, (err) => {
|
||||
if (err) {
|
||||
sendSftpError('rmdir', targetPath, err);
|
||||
} else {
|
||||
sendSftpSuccess('rmdir', targetPath);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:unlink': { // 删除文件
|
||||
const targetPath = parsedMessage.payload?.path;
|
||||
const currentSftp = ensureSftp('unlink', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string') {
|
||||
sendSftpError('unlink', targetPath, '请求路径无效。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求删除文件: ${targetPath}`);
|
||||
currentSftp.unlink(targetPath, (err) => {
|
||||
if (err) {
|
||||
sendSftpError('unlink', targetPath, err);
|
||||
} else {
|
||||
sendSftpSuccess('unlink', targetPath);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:rename': {
|
||||
const { oldPath, newPath } = parsedMessage.payload || {};
|
||||
const currentSftp = ensureSftp('rename', oldPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof oldPath !== 'string' || typeof newPath !== 'string') {
|
||||
sendSftpError('rename', oldPath, '无效的旧路径或新路径。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求重命名: ${oldPath} -> ${newPath}`);
|
||||
currentSftp.rename(oldPath, newPath, (err) => {
|
||||
if (err) {
|
||||
sendSftpError('rename', oldPath, err);
|
||||
} else {
|
||||
sendSftpSuccess('rename', oldPath, { oldPath, newPath }); // 返回新旧路径
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sftp:chmod': {
|
||||
const { targetPath, mode } = parsedMessage.payload || {};
|
||||
const currentSftp = ensureSftp('chmod', targetPath);
|
||||
if (!currentSftp) break;
|
||||
|
||||
if (typeof targetPath !== 'string' || typeof mode !== 'number') {
|
||||
sendSftpError('chmod', targetPath, '无效的路径或权限模式。');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`SFTP: 用户 ${ws.username} 请求修改权限: ${targetPath} -> ${mode.toString(8)}`); // 以八进制显示 mode
|
||||
currentSftp.chmod(targetPath, mode, (err) => {
|
||||
if (err) {
|
||||
sendSftpError('chmod', targetPath, err);
|
||||
} else {
|
||||
sendSftpSuccess('chmod', targetPath, { mode }); // 返回设置的 mode
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
console.warn(`WebSocket:收到未知类型的消息: ${parsedMessage.type}`);
|
||||
ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${parsedMessage.type}` }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket:解析消息时出错:', e);
|
||||
ws.send(JSON.stringify({ type: 'error', payload: '无效的消息格式' }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 已断开连接。代码: ${code}, 原因: ${reason.toString()}`);
|
||||
cleanupSshConnection(ws); // 清理关联的 SSH 资源
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 发生错误:`, error);
|
||||
cleanupSshConnection(ws); // 清理关联的 SSH 资源
|
||||
});
|
||||
|
||||
// 不再发送通用欢迎消息,等待前端发起 ssh:connect
|
||||
// ws.send(JSON.stringify({ type: 'info', payload: `欢迎, ${ws.username}! WebSocket 连接已建立。` }));
|
||||
});
|
||||
|
||||
wss.on('close', () => {
|
||||
console.log('WebSocket 服务器正在关闭,清理心跳定时器...');
|
||||
clearInterval(interval);
|
||||
// 关闭所有活动的 SSH 连接
|
||||
console.log('关闭所有活动的 SSH 连接...');
|
||||
activeSshConnections.forEach((conn, ws) => {
|
||||
cleanupSshConnection(ws);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('WebSocket 服务器初始化完成。');
|
||||
return wss;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// 临时脚本:用于生成 bcrypt 密码哈希
|
||||
// 使用方法: node packages/backend/temp_hash_script.js
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const saltRounds = 10; // 标准加盐轮数,安全性与性能的平衡点
|
||||
const plainPassword = 'adminpassword'; // 在这里替换为您想设置的管理员密码
|
||||
|
||||
if (!plainPassword) {
|
||||
console.error("错误:请在脚本中设置 'plainPassword' 变量的值。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`正在为密码 "${plainPassword}" 生成哈希...`);
|
||||
|
||||
bcrypt.hash(plainPassword, saltRounds, function(err, hash) {
|
||||
if (err) {
|
||||
console.error("生成哈希时出错:", err);
|
||||
return;
|
||||
}
|
||||
console.log("------------------------------------------------------");
|
||||
console.log("请将以下哈希值复制到数据库中:");
|
||||
console.log("BCrypt 哈希:", hash);
|
||||
console.log("------------------------------------------------------");
|
||||
console.log("重要提示:请妥善保管您的原始密码,此脚本仅用于生成初始哈希。");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2016", // Or a newer target like ES2020
|
||||
"module": "NodeNext", // Use NodeNext module system
|
||||
"moduleResolution": "NodeNext", // Use NodeNext resolution strategy
|
||||
"outDir": "./dist", // Output directory for compiled JS
|
||||
"rootDir": "./src", // Root directory of source files
|
||||
"esModuleInterop": true, // Enables compatibility with CommonJS modules
|
||||
"forceConsistentCasingInFileNames": true, // Enforce consistent file casing
|
||||
"strict": true, // Enable all strict type-checking options
|
||||
"skipLibCheck": true, // Skip type checking of declaration files
|
||||
"resolveJsonModule": true // Allow importing JSON files
|
||||
},
|
||||
"include": ["src/**/*"], // Include all files in the src directory
|
||||
"exclude": ["node_modules", "dist"] // Exclude node_modules and dist
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>Nexus Terminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@nexus-terminal/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.2",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-i18n": "^9.14.4",
|
||||
"vue-router": "^4.5.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useAuthStore } from './stores/auth.store'; // 引入 Auth Store
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const authStore = useAuthStore();
|
||||
const { isAuthenticated } = storeToRefs(authStore); // 获取登录状态
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app-container">
|
||||
<header>
|
||||
<nav>
|
||||
<RouterLink to="/">{{ t('nav.dashboard') }}</RouterLink> |
|
||||
<RouterLink to="/connections">{{ t('nav.connections') }}</RouterLink> |
|
||||
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
|
||||
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<RouterView /> <!-- 路由对应的组件将在这里渲染 -->
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<!-- 使用 t 函数获取应用名称 -->
|
||||
<p>© 2025 {{ t('appName') }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #f0f0f0;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin: 0 0.5rem;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #f0f0f0;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: auto; /* Pushes footer to the bottom */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
|
||||
// 定义组件发出的事件
|
||||
const emit = defineEmits(['close', 'connection-added']);
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const { isLoading, error } = storeToRefs(connectionsStore); // 获取加载和错误状态
|
||||
|
||||
// 表单数据模型
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const formError = ref<string | null>(null); // 表单级别的错误信息
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
formError.value = null; // 清除之前的错误
|
||||
|
||||
// 基础前端验证 (可以添加更复杂的验证)
|
||||
if (!formData.name || !formData.host || !formData.username || !formData.password) {
|
||||
formError.value = t('connections.form.errorRequired');
|
||||
return;
|
||||
}
|
||||
if (formData.port <= 0 || formData.port > 65535) {
|
||||
formError.value = t('connections.form.errorPort');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await connectionsStore.addConnection({
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
emit('connection-added'); // 通知父组件添加成功
|
||||
} else {
|
||||
// 如果 store action 返回 false,则显示 store 中的错误信息
|
||||
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="add-connection-form-overlay">
|
||||
<div class="add-connection-form">
|
||||
<h3>{{ t('connections.form.title') }}</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="conn-name">{{ t('connections.form.name') }}</label>
|
||||
<input type="text" id="conn-name" v-model="formData.name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-host">{{ t('connections.form.host') }}</label>
|
||||
<input type="text" id="conn-host" v-model="formData.host" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-port">{{ t('connections.form.port') }}</label>
|
||||
<input type="number" id="conn-port" v-model.number="formData.port" required min="1" max="65535" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-username">{{ t('connections.form.username') }}</label>
|
||||
<input type="text" id="conn-username" v-model="formData.username" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-password">{{ t('connections.form.password') }}</label>
|
||||
<input type="password" id="conn-password" v-model="formData.password" required />
|
||||
<!-- 提示:MVP 仅支持密码认证 -->
|
||||
</div>
|
||||
|
||||
<div v-if="formError || error" class="error-message">
|
||||
{{ formError || error }} <!-- 保持显示具体错误 -->
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('connections.form.adding') : t('connections.form.confirm') }}
|
||||
</button>
|
||||
<button type="button" @click="emit('close')" :disabled="isLoading">{{ t('connections.form.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-connection-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000; /* Ensure it's on top */
|
||||
}
|
||||
|
||||
.add-connection-form {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box; /* Include padding and border in element's total width and height */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-actions button[type="submit"] {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-actions button[type="button"] {
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router'; // 引入 useRouter
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const router = useRouter(); // 获取 router 实例
|
||||
const connectionsStore = useConnectionsStore();
|
||||
// 使用 storeToRefs 来保持 state 属性的响应性
|
||||
const { connections, isLoading, error } = storeToRefs(connectionsStore);
|
||||
|
||||
// 组件挂载时获取连接列表
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections();
|
||||
});
|
||||
|
||||
// 辅助函数:格式化时间戳
|
||||
const formatTimestamp = (timestamp: number | null): string => {
|
||||
if (!timestamp) return t('connections.status.never'); // 使用 i18n
|
||||
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
|
||||
return new Date(timestamp * 1000).toLocaleString(); // 乘以 1000 转换为毫秒
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connection-list">
|
||||
<!-- 标题移到父组件 ConnectionsView.vue 中 -->
|
||||
<div v-if="isLoading" class="loading">{{ t('connections.loading') }}</div>
|
||||
<div v-else-if="error" class="error">{{ t('connections.error', { error: error }) }}</div>
|
||||
<div v-else-if="connections.length === 0" class="no-connections">
|
||||
{{ t('connections.noConnections') }}
|
||||
</div>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('connections.table.name') }}</th>
|
||||
<th>{{ t('connections.table.host') }}</th>
|
||||
<th>{{ t('connections.table.port') }}</th>
|
||||
<th>{{ t('connections.table.user') }}</th>
|
||||
<th>{{ t('connections.table.authMethod') }}</th>
|
||||
<th>{{ t('connections.table.lastConnected') }}</th>
|
||||
<th>{{ t('connections.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="conn in connections" :key="conn.id">
|
||||
<td>{{ conn.name }}</td>
|
||||
<td>{{ conn.host }}</td>
|
||||
<td>{{ conn.port }}</td>
|
||||
<td>{{ conn.username }}</td>
|
||||
<td>{{ conn.auth_method }}</td>
|
||||
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
|
||||
<td>
|
||||
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
|
||||
<button @click="">{{ t('connections.actions.edit') }}</button> <!-- TODO: 实现编辑逻辑 -->
|
||||
<button @click="">{{ t('connections.actions.delete') }}</button> <!-- TODO: 实现删除逻辑 -->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// 在 <script setup> 之外定义需要在模板中调用的方法
|
||||
export default {
|
||||
methods: {
|
||||
connectToServer(connectionId: number) {
|
||||
console.log(`请求连接到服务器 ID: ${connectionId}`);
|
||||
// 使用 router 实例进行导航
|
||||
this.$router.push({ name: 'Workspace', params: { connectionId: connectionId.toString() } });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.loading, .error, .no-connections {
|
||||
padding: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
.no-connections {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div ref="editorContainer" class="monaco-editor-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import * as monaco from 'monaco-editor';
|
||||
|
||||
// Props for the component (will be expanded later)
|
||||
const props = defineProps({
|
||||
modelValue: { // Use modelValue for v-model support
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'plaintext', // Default language
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'vs-dark', // Default theme (can be 'vs', 'vs-dark', 'hc-black')
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Emits for v-model update
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null);
|
||||
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (editorContainer.value) {
|
||||
editorInstance = monaco.editor.create(editorContainer.value, {
|
||||
value: props.modelValue,
|
||||
language: props.language,
|
||||
theme: props.theme,
|
||||
automaticLayout: true, // Auto resize editor on container resize
|
||||
readOnly: props.readOnly,
|
||||
// Add more options as needed
|
||||
minimap: { enabled: true },
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
// Listen for content changes and emit update event for v-model
|
||||
editorInstance.onDidChangeModelContent(() => {
|
||||
if (editorInstance) {
|
||||
const currentValue = editorInstance.getValue();
|
||||
if (currentValue !== props.modelValue) {
|
||||
emit('update:modelValue', currentValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update editor content if modelValue prop changes from outside
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (editorInstance && editorInstance.getValue() !== newValue) {
|
||||
editorInstance.setValue(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
// Update language if prop changes
|
||||
watch(() => props.language, (newLanguage) => {
|
||||
if (editorInstance && editorInstance.getModel()) {
|
||||
monaco.editor.setModelLanguage(editorInstance.getModel()!, newLanguage);
|
||||
}
|
||||
});
|
||||
|
||||
// Update theme if prop changes
|
||||
watch(() => props.theme, (newTheme) => {
|
||||
if (editorInstance) {
|
||||
monaco.editor.setTheme(newTheme);
|
||||
}
|
||||
});
|
||||
|
||||
// Update readOnly status if prop changes
|
||||
watch(() => props.readOnly, (newReadOnly) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.updateOptions({ readOnly: newReadOnly });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (editorInstance) {
|
||||
editorInstance.dispose();
|
||||
editorInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose a method to get the current value if needed (optional)
|
||||
// defineExpose({
|
||||
// getValue: () => editorInstance?.getValue()
|
||||
// });
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monaco-editor-container {
|
||||
width: 100%;
|
||||
height: 100%; /* Ensure the container has height */
|
||||
min-height: 300px; /* Example minimum height */
|
||||
text-align: left; /* Ensure editor content aligns left */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import 'xterm/css/xterm.css'; // 引入 xterm 样式
|
||||
|
||||
// 定义 props 和 emits
|
||||
const props = defineProps<{
|
||||
stream?: ReadableStream<string>; // 用于接收来自 WebSocket 的数据流 (可选)
|
||||
options?: object; // xterm 的配置选项
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'data', data: string): void; // 用户输入事件
|
||||
(e: 'resize', dimensions: { cols: number; rows: number }): void; // 终端大小调整事件
|
||||
(e: 'ready', terminal: Terminal): void; // 终端准备就绪事件
|
||||
}>();
|
||||
|
||||
const terminalRef = ref<HTMLElement | null>(null); // 终端容器的引用
|
||||
let terminal: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// 初始化终端
|
||||
onMounted(() => {
|
||||
if (terminalRef.value) {
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: { // 简单主题示例
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
},
|
||||
rows: 24, // 初始行数
|
||||
cols: 80, // 初始列数
|
||||
...props.options, // 合并外部传入的选项
|
||||
});
|
||||
|
||||
// 加载插件
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
// 将终端附加到 DOM
|
||||
terminal.open(terminalRef.value);
|
||||
|
||||
// 适应容器大小
|
||||
fitAddon.fit();
|
||||
emit('resize', { cols: terminal.cols, rows: terminal.rows }); // 触发初始 resize 事件
|
||||
|
||||
// 监听用户输入
|
||||
terminal.onData((data) => {
|
||||
emit('data', data);
|
||||
});
|
||||
|
||||
// 监听终端大小变化 (通过 ResizeObserver)
|
||||
if (terminalRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon?.fit();
|
||||
} catch (e) {
|
||||
console.warn("Fit addon resize failed:", e);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
}
|
||||
|
||||
// 监听 fitAddon 的 resize 事件,获取新的尺寸并触发 emit
|
||||
// 注意:fitAddon 本身不直接触发 resize 事件,我们需要在 fit() 后手动获取
|
||||
const originalFit = fitAddon.fit.bind(fitAddon);
|
||||
fitAddon.fit = () => {
|
||||
originalFit();
|
||||
if (terminal) {
|
||||
emit('resize', { cols: terminal.cols, rows: terminal.rows });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 处理传入的数据流 (如果提供了 stream prop)
|
||||
watch(() => props.stream, async (newStream) => {
|
||||
if (newStream) {
|
||||
const reader = newStream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (terminal && value) {
|
||||
terminal.write(value); // 将流数据写入终端
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取终端流时出错:', error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}, { immediate: true }); // 立即执行一次 watch
|
||||
|
||||
// 触发 ready 事件
|
||||
emit('ready', terminal);
|
||||
|
||||
// 聚焦终端
|
||||
terminal.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载前清理资源
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver && terminalRef.value) {
|
||||
resizeObserver.unobserve(terminalRef.value);
|
||||
}
|
||||
if (terminal) {
|
||||
terminal.dispose();
|
||||
terminal = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露 write 方法给父组件 (可选)
|
||||
const write = (data: string | Uint8Array) => {
|
||||
terminal?.write(data);
|
||||
};
|
||||
defineExpose({ write });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="terminalRef" class="terminal-container"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: 100%; /* 高度需要由父容器控制 */
|
||||
overflow: hidden; /* 防止滚动条出现 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
// 导入语言文件
|
||||
import enMessages from './locales/en.json';
|
||||
import zhMessages from './locales/zh.json';
|
||||
|
||||
// 类型推断 (可选,但推荐)
|
||||
type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key
|
||||
|
||||
// 获取浏览器语言或默认语言
|
||||
const getInitialLocale = (): string => {
|
||||
const navigatorLang = navigator.language?.split('-')[0]; // 获取 'en', 'zh' 等
|
||||
if (navigatorLang === 'zh') {
|
||||
return 'zh';
|
||||
}
|
||||
// 可以添加更多语言支持
|
||||
return 'en'; // 默认英文
|
||||
};
|
||||
|
||||
const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({
|
||||
legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n
|
||||
locale: getInitialLocale(), // 设置初始语言
|
||||
fallbackLocale: 'en', // 如果当前语言缺少某个 key,则回退到英文
|
||||
messages: {
|
||||
en: enMessages,
|
||||
zh: zhMessages,
|
||||
},
|
||||
// 可选:关闭控制台的 i18n 警告 (例如缺少 key 的警告)
|
||||
// silentTranslationWarn: true,
|
||||
// silentFallbackWarn: true,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"appName": "Nexus Terminal",
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"connections": "Connections",
|
||||
"login": "Login",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"login": {
|
||||
"title": "User Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"error": "Login failed. Please check your username and password."
|
||||
},
|
||||
"connections": {
|
||||
"title": "Connection Management",
|
||||
"addConnection": "Add New Connection",
|
||||
"loading": "Loading connections...",
|
||||
"error": "Failed to load connections: {error}",
|
||||
"noConnections": "No connections yet. Click 'Add New Connection' to create one!",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"user": "User",
|
||||
"authMethod": "Auth Method",
|
||||
"lastConnected": "Last Connected",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"connect": "Connect",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"form": {
|
||||
"title": "Add New Connection",
|
||||
"name": "Name:",
|
||||
"host": "Host/IP:",
|
||||
"port": "Port:",
|
||||
"username": "Username:",
|
||||
"password": "Password:",
|
||||
"confirm": "Confirm Add",
|
||||
"adding": "Adding...",
|
||||
"cancel": "Cancel",
|
||||
"errorRequired": "All fields are required.",
|
||||
"errorPort": "Port must be between 1 and 65535.",
|
||||
"errorAdd": "Failed to add connection: {error}"
|
||||
},
|
||||
"status": {
|
||||
"never": "Never"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"statusBar": "Status: {status} (Connection ID: {id})",
|
||||
"status": {
|
||||
"initializing": "Initializing...",
|
||||
"connectingWs": "Connecting to {url}...",
|
||||
"wsConnected": "WebSocket connected, requesting SSH session...",
|
||||
"connectingSsh": "Connecting to {host}...",
|
||||
"sshConnected": "SSH connected, opening shell...",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected: {reason}",
|
||||
"wsClosed": "WebSocket closed (Code: {code})",
|
||||
"error": "Error: {message}",
|
||||
"wsError": "WebSocket connection error",
|
||||
"sshError": "SSH Error: {message}",
|
||||
"decryptError": "Cannot decrypt credentials.",
|
||||
"noConnInfo": "Connection config not found for ID {id}.",
|
||||
"noPassword": "Connection config is missing password.",
|
||||
"shellError": "Failed to open shell: {message}",
|
||||
"alreadyConnected": "An active SSH connection already exists.",
|
||||
"unknown": "Unknown status"
|
||||
},
|
||||
"terminal": {
|
||||
"infoPrefix": "[INFO]",
|
||||
"errorPrefix": "[ERROR]",
|
||||
"disconnectMsg": "--- SSH Connection Closed ({reason}) ---",
|
||||
"wsCloseMsg": "--- WebSocket Connection Closed (Code: {code}) ---",
|
||||
"wsErrorMsg": "--- WebSocket Connection Error ---",
|
||||
"decryptErrorMsg": "--- Error: Cannot decrypt credentials ---",
|
||||
"genericErrorMsg": "--- Error: {message} ---"
|
||||
}
|
||||
},
|
||||
"fileManager": {
|
||||
"currentPath": "Current Path",
|
||||
"loading": "Loading directory...",
|
||||
"emptyDirectory": "Directory is empty",
|
||||
"uploadTasks": "Upload Tasks",
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"parentDirectory": "Parent Directory",
|
||||
"uploadFile": "Upload File",
|
||||
"upload": "Upload",
|
||||
"newFolder": "New Folder",
|
||||
"rename": "Rename",
|
||||
"changePermissions": "Change Permissions",
|
||||
"delete": "Delete",
|
||||
"deleteMultiple": "Delete {count} items",
|
||||
"download": "Download",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"headers": {
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"permissions": "Permissions",
|
||||
"modified": "Modified",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"uploadStatus": {
|
||||
"pending": "Pending",
|
||||
"uploading": "Uploading",
|
||||
"paused": "Paused",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Error",
|
||||
"websocketNotConnected": "WebSocket not connected",
|
||||
"missingConnectionId": "Cannot get current connection ID",
|
||||
"createFolderFailed": "Failed to create folder",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"renameFailed": "Failed to rename",
|
||||
"chmodFailed": "Failed to change permissions",
|
||||
"invalidPermissionsFormat": "Invalid permissions format. Please enter 3 or 4 octal digits (e.g., 755 or 0755).",
|
||||
"readFileError": "Error reading file",
|
||||
"readFileFailed": "Failed to read file",
|
||||
"fileDecodeError": "File decoding failed (likely not UTF-8)",
|
||||
"saveFailed": "Failed to save file",
|
||||
"saveTimeout": "Save timed out"
|
||||
},
|
||||
"prompts": {
|
||||
"enterFolderName": "Enter the name for the new folder:",
|
||||
"confirmOverwrite": "File \"{name}\" already exists. Overwrite?",
|
||||
"confirmDeleteMultiple": "Are you sure you want to delete the selected {count} items? This cannot be undone.",
|
||||
"confirmDeleteFolder": "Are you sure you want to delete the directory \"{name}\" and all its contents? This cannot be undone.",
|
||||
"confirmDeleteFile": "Are you sure you want to delete the file \"{name}\"? This cannot be undone.",
|
||||
"enterNewName": "Enter the new name for \"{oldName}\":",
|
||||
"enterNewPermissions": "Enter new permissions for \"{name}\" (octal, e.g., 755):"
|
||||
},
|
||||
"editingFile": "Editing",
|
||||
"loadingFile": "Loading file...",
|
||||
"saving": "Saving",
|
||||
"saveSuccess": "Save successful",
|
||||
"saveError": "Save error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"appName": "星枢终端",
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"connections": "连接管理",
|
||||
"login": "登录",
|
||||
"logout": "登出"
|
||||
},
|
||||
"login": {
|
||||
"title": "用户登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"loginButton": "登录",
|
||||
"loggingIn": "正在登录...",
|
||||
"error": "登录失败,请检查用户名或密码。"
|
||||
},
|
||||
"connections": {
|
||||
"title": "连接管理",
|
||||
"addConnection": "添加新连接",
|
||||
"loading": "正在加载连接...",
|
||||
"error": "加载连接失败: {error}",
|
||||
"noConnections": "还没有任何连接。点击“添加新连接”来创建一个吧!",
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"host": "主机",
|
||||
"port": "端口",
|
||||
"user": "用户名",
|
||||
"authMethod": "认证方式",
|
||||
"lastConnected": "上次连接",
|
||||
"actions": "操作"
|
||||
},
|
||||
"actions": {
|
||||
"connect": "连接",
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
"form": {
|
||||
"title": "添加新连接",
|
||||
"name": "名称:",
|
||||
"host": "主机/IP:",
|
||||
"port": "端口:",
|
||||
"username": "用户名:",
|
||||
"password": "密码:",
|
||||
"confirm": "确认添加",
|
||||
"adding": "正在添加...",
|
||||
"cancel": "取消",
|
||||
"errorRequired": "所有字段均为必填项。",
|
||||
"errorPort": "端口号必须在 1 到 65535 之间。",
|
||||
"errorAdd": "添加连接失败: {error}"
|
||||
},
|
||||
"status": {
|
||||
"never": "从未"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"statusBar": "状态: {status} (连接 ID: {id})",
|
||||
"status": {
|
||||
"initializing": "正在初始化...",
|
||||
"connectingWs": "正在连接到 {url}...",
|
||||
"wsConnected": "WebSocket 已连接,正在请求 SSH 会话...",
|
||||
"connectingSsh": "正在连接到 {host}...",
|
||||
"sshConnected": "SSH 连接成功,正在打开 Shell...",
|
||||
"connected": "已连接",
|
||||
"disconnected": "已断开: {reason}",
|
||||
"wsClosed": "WebSocket 已关闭 (代码: {code})",
|
||||
"error": "错误: {message}",
|
||||
"wsError": "WebSocket 连接错误",
|
||||
"sshError": "SSH 错误: {message}",
|
||||
"decryptError": "无法解密连接凭证。",
|
||||
"noConnInfo": "未找到 ID 为 {id} 的连接配置。",
|
||||
"noPassword": "连接配置缺少密码信息。",
|
||||
"shellError": "打开 Shell 失败: {message}",
|
||||
"alreadyConnected": "已存在活动的 SSH 连接。",
|
||||
"unknown": "未知状态"
|
||||
},
|
||||
"terminal": {
|
||||
"infoPrefix": "[信息]",
|
||||
"errorPrefix": "[错误]",
|
||||
"disconnectMsg": "--- SSH 连接已关闭 ({reason}) ---",
|
||||
"wsCloseMsg": "--- WebSocket 连接已关闭 (代码: {code}) ---",
|
||||
"wsErrorMsg": "--- WebSocket 连接错误 ---",
|
||||
"decryptErrorMsg": "--- 错误:无法解密连接凭证 ---",
|
||||
"genericErrorMsg": "--- 错误: {message} ---"
|
||||
}
|
||||
},
|
||||
"fileManager": {
|
||||
"currentPath": "当前路径",
|
||||
"loading": "正在加载目录...",
|
||||
"emptyDirectory": "目录为空",
|
||||
"uploadTasks": "上传任务",
|
||||
"actions": {
|
||||
"refresh": "刷新",
|
||||
"parentDirectory": "上一级",
|
||||
"uploadFile": "上传文件",
|
||||
"upload": "上传",
|
||||
"newFolder": "新建文件夹",
|
||||
"rename": "重命名",
|
||||
"changePermissions": "修改权限",
|
||||
"delete": "删除",
|
||||
"deleteMultiple": "删除 {count} 个项目",
|
||||
"download": "下载",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
},
|
||||
"headers": {
|
||||
"type": "类型",
|
||||
"name": "名称",
|
||||
"size": "大小",
|
||||
"permissions": "权限",
|
||||
"modified": "修改时间",
|
||||
"actions": "操作"
|
||||
},
|
||||
"uploadStatus": {
|
||||
"pending": "等待中",
|
||||
"uploading": "上传中",
|
||||
"paused": "已暂停",
|
||||
"success": "成功",
|
||||
"error": "错误",
|
||||
"cancelled": "已取消"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "错误",
|
||||
"websocketNotConnected": "WebSocket 未连接",
|
||||
"missingConnectionId": "无法获取当前连接 ID",
|
||||
"createFolderFailed": "创建文件夹失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"renameFailed": "重命名失败",
|
||||
"chmodFailed": "修改权限失败",
|
||||
"invalidPermissionsFormat": "无效的权限格式。请输入 3 或 4 位八进制数字 (例如 755 或 0755)。",
|
||||
"readFileError": "读取文件时出错",
|
||||
"readFileFailed": "读取文件失败",
|
||||
"fileDecodeError": "文件解码失败 (可能不是 UTF-8 编码)",
|
||||
"saveFailed": "保存文件失败",
|
||||
"saveTimeout": "保存超时"
|
||||
},
|
||||
"prompts": {
|
||||
"enterFolderName": "请输入新文件夹的名称:",
|
||||
"confirmOverwrite": "文件 \"{name}\" 已存在。是否覆盖?",
|
||||
"confirmDeleteMultiple": "确定要删除选定的 {count} 个项目吗?此操作不可撤销。",
|
||||
"confirmDeleteFolder": "确定要删除目录 \"{name}\" 及其所有内容吗?此操作不可撤销。",
|
||||
"confirmDeleteFile": "确定要删除文件 \"{name}\" 吗?此操作不可撤销。",
|
||||
"enterNewName": "请输入 \"{oldName}\" 的新名称:",
|
||||
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):"
|
||||
},
|
||||
"editingFile": "正在编辑",
|
||||
"loadingFile": "正在加载文件...",
|
||||
"saving": "正在保存",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveError": "保存出错"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia'; // 引入 Pinia
|
||||
import App from './App.vue';
|
||||
import router from './router'; // 引入我们创建的 router
|
||||
import i18n from './i18n'; // 引入 i18n 实例
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia()); // 使用 Pinia
|
||||
app.use(router); // 使用 Router
|
||||
app.use(i18n); // 使用 i18n
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth.store'; // 导入 Auth Store
|
||||
|
||||
// 路由配置
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
// 首页/仪表盘 (占位符)
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
// component: () => import('../views/DashboardView.vue') // 稍后创建
|
||||
component: { template: '<div>仪表盘 (建设中)</div>' } // 临时占位
|
||||
},
|
||||
// 登录页面 (占位符)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue') // 指向实际的登录组件
|
||||
},
|
||||
// 连接管理页面
|
||||
{
|
||||
path: '/connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue')
|
||||
},
|
||||
// 工作区页面,需要 connectionId 参数
|
||||
{
|
||||
path: '/workspace/:connectionId', // 使用动态路由段
|
||||
name: 'Workspace',
|
||||
component: () => import('../views/WorkspaceView.vue'),
|
||||
props: true // 将路由参数作为 props 传递给组件
|
||||
},
|
||||
// 其他路由...
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL), // 使用 HTML5 History 模式
|
||||
routes,
|
||||
});
|
||||
|
||||
// 添加全局前置守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 在守卫内部获取 store 实例,确保 Pinia 已初始化
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const requiresAuth = !['Login'].includes(to.name as string); // 需要认证的路由 (除了登录页)
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
// 如果需要认证但用户未登录,重定向到登录页
|
||||
console.log('路由守卫:未登录,重定向到 /login');
|
||||
next({ name: 'Login' });
|
||||
} else if (to.name === 'Login' && authStore.isAuthenticated) {
|
||||
// 如果用户已登录但尝试访问登录页,重定向到仪表盘
|
||||
console.log('路由守卫:已登录,从 /login 重定向到 /');
|
||||
next({ name: 'Dashboard' });
|
||||
} else {
|
||||
// 其他情况允许导航
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import router from '../router'; // 引入 router 用于重定向
|
||||
|
||||
// 用户信息接口 (不含敏感信息)
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// Auth Store State 接口
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: UserInfo | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
isAuthenticated: false, // 初始为未登录
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
loggedInUser: (state) => state.user?.username,
|
||||
},
|
||||
actions: {
|
||||
// 登录 Action
|
||||
async login(credentials: { username: string; password: string }) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login', credentials);
|
||||
// 登录成功
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user;
|
||||
console.log('登录成功:', this.user);
|
||||
// 登录成功后重定向到连接管理页面 (或仪表盘)
|
||||
await router.push({ name: 'Connections' }); // 使用 await 确保导航完成
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error('登录失败:', err);
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.error = err.response?.data?.message || err.message || '登录时发生未知错误。';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 登出 Action (占位符)
|
||||
async logout() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
// TODO: 调用后端的登出 API (如果需要)
|
||||
// await axios.post('/api/v1/auth/logout');
|
||||
|
||||
// 清除本地状态
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
console.log('已登出');
|
||||
// 登出后重定向到登录页
|
||||
await router.push({ name: 'Login' });
|
||||
} catch (err: any) {
|
||||
console.error('登出失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || '登出时发生未知错误。';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
|
||||
// async checkAuthStatus() { ... }
|
||||
},
|
||||
// 可选:开启持久化 (例如使用 pinia-plugin-persistedstate)
|
||||
// persist: true,
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios'; // 引入 axios
|
||||
|
||||
// 定义连接信息接口 (与后端对应,不含敏感信息)
|
||||
export interface ConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
}
|
||||
|
||||
// 定义 Store State 的接口
|
||||
interface ConnectionsState {
|
||||
connections: ConnectionInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 定义 Pinia Store
|
||||
export const useConnectionsStore = defineStore('connections', {
|
||||
state: (): ConnectionsState => ({
|
||||
connections: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
actions: {
|
||||
// 获取连接列表 Action
|
||||
async fetchConnections() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
// 注意:axios 默认会携带 cookie,因此如果用户已登录,会话 cookie 会被发送
|
||||
const response = await axios.get<ConnectionInfo[]>('/api/v1/connections');
|
||||
this.connections = response.data;
|
||||
} catch (err: any) {
|
||||
console.error('获取连接列表失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || '获取连接列表时发生未知错误。';
|
||||
// 如果是 401 未授权,可能需要触发重新登录逻辑
|
||||
if (err.response?.status === 401) {
|
||||
// TODO: 处理未授权情况,例如跳转到登录页
|
||||
console.warn('未授权,需要登录才能获取连接列表。');
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 添加新连接 Action
|
||||
async addConnection(newConnectionData: { name: string; host: string; port: number; username: string; password: string }) {
|
||||
this.isLoading = true; // 可以为添加操作单独设置加载状态,或共用 isLoading
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.post<{ message: string; connection: ConnectionInfo }>('/api/v1/connections', newConnectionData);
|
||||
// 添加成功后,将新连接添加到列表前面 (或重新获取整个列表)
|
||||
this.connections.unshift(response.data.connection);
|
||||
return true; // 表示成功
|
||||
} catch (err: any) {
|
||||
console.error('添加连接失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || '添加连接时发生未知错误。';
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('未授权,需要登录才能添加连接。');
|
||||
}
|
||||
return false; // 表示失败
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
/* Global styles will go here */
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import ConnectionList from '../components/ConnectionList.vue'; // 引入列表组件
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue'; // 引入表单组件
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const showAddForm = ref(false); // 控制添加表单的显示状态
|
||||
|
||||
const handleConnectionAdded = () => {
|
||||
showAddForm.value = false; // 添加成功后隐藏表单
|
||||
// ConnectionList 组件会自动从 store 获取更新后的列表
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connections-view">
|
||||
<h2>{{ t('connections.title') }}</h2>
|
||||
|
||||
<button @click="showAddForm = true" v-if="!showAddForm">{{ t('connections.addConnection') }}</button>
|
||||
|
||||
<!-- 添加连接表单 (条件渲染) -->
|
||||
<AddConnectionForm
|
||||
v-if="showAddForm"
|
||||
@close="showAddForm = false"
|
||||
@connection-added="handleConnectionAdded"
|
||||
/>
|
||||
|
||||
<!-- 连接列表 -->
|
||||
<ConnectionList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connections-view {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const authStore = useAuthStore();
|
||||
const { isLoading, error } = storeToRefs(authStore); // 获取加载和错误状态
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
// 处理登录提交
|
||||
const handleLogin = async () => {
|
||||
await authStore.login(credentials);
|
||||
// 登录成功会自动重定向 (在 store action 中处理)
|
||||
// 登录失败会在模板中显示错误信息
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-view">
|
||||
<div class="login-form-container">
|
||||
<h2>{{ t('login.title') }}</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ t('login.username') }}:</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ t('login.password') }}:</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<!-- 可以直接显示后端返回的错误,或者映射到特定的 i18n key -->
|
||||
{{ error }} <!-- 保持显示后端错误,或者 t('login.error') -->
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('login.loggingIn') : t('login.loginButton') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-view {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 150px); /* Adjust based on header/footer height */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
background-color: #fff;
|
||||
padding: 2rem 3rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #eee;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #a0cfff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import TerminalComponent from '../components/Terminal.vue'; // 引入终端组件
|
||||
import FileManagerComponent from '../components/FileManager.vue'; // 引入文件管理器组件
|
||||
import type { Terminal } from 'xterm'; // 引入 Terminal 类型
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const route = useRoute();
|
||||
const connectionId = computed(() => route.params.connectionId as string); // 从路由获取 connectionId
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null); // 终端实例引用
|
||||
const ws = ref<WebSocket | null>(null); // WebSocket 实例引用
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const statusMessage = ref<string>(t('workspace.status.initializing')); // 使用 i18n
|
||||
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
|
||||
// 辅助函数:根据状态码获取 i18n 状态文本
|
||||
const getStatusText = (statusKey: string, params?: Record<string, any>): string => {
|
||||
return t(`workspace.status.${statusKey}`, params || {});
|
||||
};
|
||||
|
||||
// 辅助函数:获取终端消息文本
|
||||
const getTerminalText = (key: string, params?: Record<string, any>): string => {
|
||||
return t(`workspace.terminal.${key}`, params || {});
|
||||
};
|
||||
|
||||
|
||||
// 处理终端准备就绪事件
|
||||
const onTerminalReady = (term: Terminal) => {
|
||||
terminalInstance.value = term;
|
||||
// 将缓冲区的输出写入终端
|
||||
terminalOutputBuffer.value.forEach(data => term.write(data));
|
||||
terminalOutputBuffer.value = []; // 清空缓冲区
|
||||
console.log('终端准备就绪');
|
||||
};
|
||||
|
||||
// 处理终端用户输入
|
||||
const onTerminalData = (data: string) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:input', payload: { data } }));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理终端大小调整
|
||||
const onTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
console.log('发送终端大小调整:', dimensions);
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:resize', payload: dimensions }));
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
const initializeWebSocketConnection = () => {
|
||||
// 使用当前页面的协议和主机,但端口固定为后端端口 (3001),路径为 /
|
||||
// 注意:这里假设后端 WebSocket 监听根路径,如果不是,需要修改路径
|
||||
// 并且假设前端和后端在同一主机上,只是端口不同
|
||||
const wsUrl = `ws://${window.location.hostname}:3001`; // 构建 WebSocket URL
|
||||
|
||||
console.log(`尝试连接 WebSocket: ${wsUrl}`);
|
||||
statusMessage.value = getStatusText('connectingWs', { url: wsUrl });
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
ws.value = new WebSocket(wsUrl);
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket 连接已打开');
|
||||
statusMessage.value = getStatusText('wsConnected');
|
||||
// 连接打开后,发送 ssh:connect 消息
|
||||
if (ws.value) {
|
||||
ws.value.send(JSON.stringify({ type: 'ssh:connect', payload: { connectionId: connectionId.value } }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
// console.log('收到 WebSocket 消息:', message); // Debug log
|
||||
|
||||
switch (message.type) {
|
||||
case 'ssh:output':
|
||||
let outputData = message.payload;
|
||||
// 检查是否为 Base64 编码
|
||||
if (message.encoding === 'base64' && typeof outputData === 'string') {
|
||||
try {
|
||||
// 解码 Base64 并尝试用 UTF-8 解释
|
||||
// 注意:atob 在浏览器中可用,但在 Node.js 环境中可能需要 Buffer.from(..., 'base64').toString()
|
||||
outputData = atob(outputData);
|
||||
} catch (e) {
|
||||
console.error('Base64 解码失败:', e, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
|
||||
}
|
||||
}
|
||||
// 写入终端
|
||||
if (terminalInstance.value) {
|
||||
terminalInstance.value.write(outputData);
|
||||
} else {
|
||||
// 如果终端还没准备好,先缓冲输出 (缓冲解码后的数据)
|
||||
terminalOutputBuffer.value.push(message.payload);
|
||||
}
|
||||
break;
|
||||
case 'ssh:connected':
|
||||
console.log('SSH 会话已连接');
|
||||
connectionStatus.value = 'connected';
|
||||
statusMessage.value = getStatusText('connected');
|
||||
terminalInstance.value?.focus(); // 连接成功后聚焦终端
|
||||
break;
|
||||
case 'ssh:disconnected':
|
||||
const reasonDisconnect = message.payload || '未知原因';
|
||||
console.log('SSH 会话已断开:', reasonDisconnect);
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('disconnected', { reason: reasonDisconnect });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason: reasonDisconnect })}\x1b[0m`);
|
||||
break;
|
||||
case 'ssh:error':
|
||||
const errorMsg = message.payload || '未知 SSH 错误';
|
||||
console.error('SSH 错误:', errorMsg);
|
||||
connectionStatus.value = 'error';
|
||||
// 尝试匹配特定的错误 key
|
||||
let errorKey = 'sshError';
|
||||
if (errorMsg.includes('解密')) errorKey = 'decryptError';
|
||||
else if (errorMsg.includes('未找到 ID')) errorKey = 'noConnInfo';
|
||||
else if (errorMsg.includes('缺少密码')) errorKey = 'noPassword';
|
||||
else if (errorMsg.includes('打开 Shell 失败')) errorKey = 'shellError';
|
||||
else if (errorMsg.includes('已存在活动的 SSH 连接')) errorKey = 'alreadyConnected';
|
||||
|
||||
statusMessage.value = getStatusText(errorKey, { message: errorMsg, id: connectionId.value });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
break;
|
||||
case 'ssh:status':
|
||||
const statusKey = message.payload?.key || 'unknown'; // 假设后端会发送 key
|
||||
const statusParams = message.payload?.params || {};
|
||||
console.log('SSH 状态:', statusKey, statusParams);
|
||||
statusMessage.value = getStatusText(statusKey, statusParams); // 更新状态信息
|
||||
break;
|
||||
case 'info': // 处理后端发送的普通信息
|
||||
console.log('后端信息:', message.payload);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${message.payload}\x1b[0m`);
|
||||
break;
|
||||
case 'error': // 处理后端发送的通用错误
|
||||
console.error('后端错误:', message.payload);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('error', { message: message.payload });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${message.payload}\x1b[0m`);
|
||||
break;
|
||||
// default: // Removed default case to allow other components to handle messages
|
||||
// console.warn('WorkspaceView: 收到未处理的 WebSocket 消息类型:', message.type);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('处理 WebSocket 消息时出错:', e);
|
||||
// 如果收到的不是 JSON,直接写入终端
|
||||
if (terminalInstance.value && typeof event.data === 'string') {
|
||||
terminalInstance.value.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
connectionStatus.value = 'error';
|
||||
statusMessage.value = getStatusText('wsError');
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsErrorMsg')}\x1b[0m`);
|
||||
};
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log('WebSocket 连接已关闭:', event.code, event.reason);
|
||||
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = getStatusText('wsClosed', { code: event.code });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsCloseMsg', { code: event.code })}\x1b[0m`);
|
||||
}
|
||||
ws.value = null; // 清理引用
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (connectionId.value) {
|
||||
initializeWebSocketConnection();
|
||||
} else {
|
||||
statusMessage.value = getStatusText('error', { message: '缺少连接 ID' });
|
||||
connectionStatus.value = 'error';
|
||||
console.error('WorkspaceView: 缺少 connectionId 路由参数。');
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (ws.value) {
|
||||
console.log('组件卸载,关闭 WebSocket 连接...');
|
||||
ws.value.close();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-view">
|
||||
<div class="status-bar">
|
||||
<!-- 使用 t 函数渲染状态栏文本 -->
|
||||
{{ t('workspace.statusBar', { status: statusMessage, id: connectionId }) }}
|
||||
<!-- 状态颜色仍然通过 class 绑定 -->
|
||||
<span :class="`status-${connectionStatus}`"></span>
|
||||
</div>
|
||||
<div class="terminal-wrapper">
|
||||
<TerminalComponent
|
||||
@ready="onTerminalReady"
|
||||
@data="onTerminalData"
|
||||
@resize="onTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<FileManagerComponent :ws="ws" :is-connected="connectionStatus === 'connected'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 调整高度计算以适应可能的 header/footer/status-bar */
|
||||
height: calc(100vh - 60px - 30px - 2rem); /* 假设 header 60px, footer 30px, padding 2rem */
|
||||
overflow: hidden; /* 防止页面滚动 */
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #eee;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-connecting { color: orange; }
|
||||
.status-connected { color: green; }
|
||||
.status-disconnected { color: grey; }
|
||||
.status-error { color: red; }
|
||||
|
||||
.terminal-wrapper {
|
||||
/* flex-grow: 1; */ /* 不再让终端独占剩余空间 */
|
||||
height: 60%; /* 示例:终端占 60% 高度 */
|
||||
background-color: #1e1e1e; /* 终端背景色 */
|
||||
overflow: hidden; /* 内部滚动由 xterm 处理 */
|
||||
}
|
||||
|
||||
.file-manager-wrapper {
|
||||
height: 40%; /* 示例:文件管理器占 40% 高度 */
|
||||
border-top: 2px solid #ccc; /* 添加分隔线 */
|
||||
overflow: hidden; /* 防止自身滚动 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext", // 编译目标为最新 ECMAScript 标准
|
||||
"useDefineForClassFields": true, // 使用标准类字段定义
|
||||
"module": "ESNext", // 使用 ES 模块系统
|
||||
"moduleResolution": "Node", // 模块解析策略
|
||||
"strict": true, // 启用所有严格类型检查选项
|
||||
"jsx": "preserve", // 保留 JSX 语法供后续处理 (如 Vue)
|
||||
"sourceMap": true, // 生成 Source Map 文件
|
||||
"resolveJsonModule": true, // 允许导入 JSON 文件
|
||||
"isolatedModules": true, // 确保每个文件都可以安全地单独编译
|
||||
"esModuleInterop": true, // 允许从 CommonJS 模块中默认导入
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"], // 包含的库定义
|
||||
"skipLibCheck": true, // 跳过对声明文件 (*.d.ts) 的类型检查
|
||||
"noEmit": true, // 不输出编译后的文件 (由 Vite 处理构建)
|
||||
"baseUrl": ".", // 基础目录,用于解析非相对模块名
|
||||
"paths": {
|
||||
"@/*": ["src/*"] // 路径别名,例如 @/components/* 指向 src/components/*
|
||||
},
|
||||
"types": ["vite/client"] // **关键:包含 Vite 客户端类型定义**
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], // 需要进行类型检查的文件
|
||||
"exclude": ["node_modules"] // 排除检查的目录
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// @ts-ignore because the plugin type might not perfectly match Vite's expected PluginOption type
|
||||
monacoEditorPlugin({})
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
// 将所有 /api 开头的请求代理到后端服务器
|
||||
'/api': {
|
||||
target: 'http://localhost:3001', // 后端服务器地址
|
||||
changeOrigin: true, // 需要虚拟主机站点
|
||||
// 可选:如果后端 API 路径没有 /api 前缀,可以在这里重写路径
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user