This commit is contained in:
Baobhan Sith
2025-04-14 22:51:05 +08:00
parent 286492fc63
commit a974b8b1d9
49 changed files with 13954 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+20
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+4
View File
@@ -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>
+8
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+4703
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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": ""
}
+3307
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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 = ...
+14
View File
@@ -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;
+59
View File
@@ -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;
+157
View File
@@ -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);
+86
View File
@@ -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 = ...
+15
View File
@@ -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;
+78
View File
@@ -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('解密过程中发生错误或数据无效');
}
};
+771
View File
@@ -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;
};
+25
View File
@@ -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("重要提示:请妥善保管您的原始密码,此脚本仅用于生成初始哈希。");
});
+16
View File
@@ -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.
+13
View File
@@ -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>
+1524
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+77
View File
@@ -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>&copy; 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>
+33
View File
@@ -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;
+151
View File
@@ -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"
}
}
+151
View File
@@ -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": "保存出错"
}
}
+14
View File
@@ -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');
+61
View File
@@ -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;
}
},
},
});
+1
View File
@@ -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>
+129
View File
@@ -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>
+24
View File
@@ -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"] //
}
+23
View File
@@ -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/, '')
}
}
}
})