@@ -1,6 +1,8 @@
|
||||
# local/docker
|
||||
DEPLOYMENT_MODE=docker
|
||||
DEPLOYMENT_MODE=local
|
||||
|
||||
RDP_SERVICE_URL_DOCKER=ws://rdp:8081
|
||||
|
||||
RDP_SERVICE_URL_LOCAL=ws://localhost:8081
|
||||
# Backend API Base URLs
|
||||
REMOTE_GATEWAY_API_BASE_LOCAL=http://localhost:9090
|
||||
REMOTE_GATEWAY_API_BASE_DOCKER=http://remote-gateway:9090
|
||||
REMOTE_GATEWAY_WS_URL_LOCAL=ws://localhost:8080
|
||||
REMOTE_GATEWAY_WS_URL_DOCKER=ws://remote-gateway:8080
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
- rdp
|
||||
- vnc
|
||||
networks:
|
||||
- nexus-terminal-network
|
||||
|
||||
@@ -27,6 +28,7 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
RDP_BACKEND_API_BASE: http://rdp:9090
|
||||
VNC_BACKEND_API_BASE: http://vnc:9091
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
@@ -52,6 +54,30 @@ services:
|
||||
- guacd
|
||||
- backend
|
||||
|
||||
vnc:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/vnc/Dockerfile
|
||||
image: nexus-vnc # 保持与 docker-compose.yml 中的 image 名称一致
|
||||
container_name: nexus-vnc
|
||||
ports:
|
||||
- "9091:9091"
|
||||
- "8082:8082"
|
||||
environment:
|
||||
GUACD_HOSTNAME: guacd
|
||||
GUACD_PORT: 4822
|
||||
VNC_PORT: 9091
|
||||
VNC_WS_PORT: 8082
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
FRONTEND_URL: ${FRONTEND_URL}
|
||||
MAIN_BACKEND_URL: ${MAIN_BACKEND_URL}
|
||||
NODE_ENV: production
|
||||
networks:
|
||||
- nexus-terminal-network
|
||||
depends_on:
|
||||
- guacd
|
||||
- backend
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: nexus-terminal-guacd
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ services:
|
||||
- "18111:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- rdp
|
||||
- remote-gateway
|
||||
networks:
|
||||
- nexus-terminal-network
|
||||
|
||||
@@ -14,24 +14,24 @@ services:
|
||||
image: heavrnl/nexus-terminal-backend:latest
|
||||
container_name: nexus-terminal-backend
|
||||
env_file:
|
||||
- .env
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
RDP_BACKEND_API_BASE: http://rdp:9090
|
||||
REMOTE_GATEWAY_API_BASE: http://remote-gateway:9090
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- nexus-terminal-network
|
||||
|
||||
rdp:
|
||||
image: heavrnl/nexus-terminal-rdp:latest
|
||||
container_name: nexus-terminal-rdp
|
||||
remote-gateway:
|
||||
image: heavrnl/nexus-terminal-remote-gateway:latest
|
||||
container_name: nexus-terminal-remote-gateway
|
||||
environment:
|
||||
GUACD_HOST: guacd
|
||||
GUACD_PORT: 4822
|
||||
API_PORT: 9090
|
||||
GUAC_WS_PORT: 8081
|
||||
REMOTE_GATEWAY_API_PORT: 9090
|
||||
REMOTE_GATEWAY_WS_PORT: 8080
|
||||
FRONTEND_URL: http://frontend
|
||||
MAIN_BACKEND_URL: http://backend:3001
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
||||
# Copy workspace package.json files to ensure npm ci works correctly in monorepo
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/rdp/package.json ./packages/rdp/
|
||||
COPY packages/remote-gateway/package.json ./packages/remote-gateway/
|
||||
|
||||
# Install dependencies (using install instead of ci for potential armv7/alpine compatibility issues)
|
||||
RUN npm install
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as ConnectionService from '../services/connection.service';
|
||||
import * as SshService from '../services/ssh.service';
|
||||
import * as GuacamoleService from '../services/guacamole.service'; // 导入 GuacamoleService
|
||||
import * as ImportExportService from '../services/import-export.service';
|
||||
import * as ConnectionRepository from '../repositories/connection.repository'; // +++ 导入 ConnectionRepository +++
|
||||
|
||||
@@ -269,10 +270,9 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
};
|
||||
import axios from 'axios'; // +++ Import axios +++
|
||||
import axios from 'axios'; // axios 仍可能用于错误检查类型
|
||||
|
||||
// TODO: Make RDP backend URL configurable
|
||||
const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://localhost:9090';
|
||||
// RDP_BACKEND_API_BASE and VNC_BACKEND_API_BASE are now handled in GuacamoleService
|
||||
|
||||
/**
|
||||
* 获取 RDP 会话的 Guacamole 令牌 (通过调用 RDP 后端)
|
||||
@@ -320,66 +320,141 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 准备调用 RDP 后端的参数
|
||||
const rdpApiParams = new URLSearchParams({
|
||||
hostname: connection.host,
|
||||
port: connection.port.toString(),
|
||||
username: connection.username,
|
||||
password: decryptedPassword, // 使用解密后的密码
|
||||
// Add other RDP parameters from connection object if needed by rdp backend
|
||||
security: (connection as any).rdp_security || 'any',
|
||||
ignoreCert: String((connection as any).rdp_ignore_cert ?? true),
|
||||
});
|
||||
const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`;
|
||||
// 4. 调用 GuacamoleService 获取 RDP 令牌
|
||||
// 注意:从 connection.extras 或其他地方获取 RDP 特定的 width, height, dpi
|
||||
const { width, height, dpi } = req.query; // 或者从 connection.extras 获取
|
||||
const rdpWidth = width ? parseInt(width as string, 10) : undefined;
|
||||
const rdpHeight = height ? parseInt(height as string, 10) : undefined;
|
||||
const rdpDpi = dpi ? dpi as string : undefined;
|
||||
|
||||
console.log(`[Controller:getRdpSessionToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
|
||||
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('rdp', connection, decryptedPassword, rdpWidth, rdpHeight, rdpDpi);
|
||||
|
||||
console.log(`[Controller:getRdpSessionToken] Received Guacamole token via GuacamoleService for RDP connection ${connectionId}`);
|
||||
|
||||
// 5. 调用 RDP 后端 API 获取 Guacamole 令牌
|
||||
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
|
||||
timeout: 10000 // 设置 10 秒超时
|
||||
});
|
||||
|
||||
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
|
||||
console.error(`[Controller:getRdpSessionToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
|
||||
throw new Error('从 RDP 后端获取令牌失败。');
|
||||
}
|
||||
|
||||
const guacamoleToken = rdpResponse.data.token;
|
||||
console.log(`[Controller:getRdpSessionToken] Received Guacamole token from RDP backend for connection ${connectionId}`);
|
||||
|
||||
// 6. 将 Guacamole 令牌返回给前端
|
||||
// 5. 将 Guacamole 令牌返回给前端
|
||||
res.status(200).json({ token: guacamoleToken });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error);
|
||||
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
|
||||
|
||||
let statusCode = 500;
|
||||
let message = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||
let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
message = '调用 RDP 后端服务时出错。';
|
||||
if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (RDP)')) {
|
||||
responseMessage = error.message;
|
||||
if (error.message.includes('(状态: 4')) statusCode = 400;
|
||||
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
||||
else statusCode = 503;
|
||||
} else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('RDP 连接使用密码认证,但密码解密失败或未提供密码')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
} else if (error.message.includes('连接类型必须是 RDP')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
}
|
||||
else if (axios.isAxiosError(error)) {
|
||||
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
|
||||
if (error.response) {
|
||||
// RDP 后端返回了错误响应
|
||||
console.error('[Controller:getRdpSessionToken] RDP backend error response:', error.response.data);
|
||||
message += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400; // Bad Gateway or Bad Request
|
||||
console.error('[Controller:getRdpSessionToken] Remote Gateway error response:', error.response.data);
|
||||
responseMessage += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应 (网络问题、超时)
|
||||
console.error('[Controller:getRdpSessionToken] No response from RDP backend.');
|
||||
message += ' (无法连接或超时)';
|
||||
statusCode = 504; // Gateway Timeout
|
||||
} else {
|
||||
// 设置请求时发生错误
|
||||
console.error('[Controller:getRdpSessionToken] Axios request setup error:', error.message);
|
||||
console.error('[Controller:getRdpSessionToken] No response from Remote Gateway.');
|
||||
responseMessage += ' (无法连接或超时)';
|
||||
statusCode = 504;
|
||||
}
|
||||
} else if (error.message.includes('解密失败')) {
|
||||
message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
}
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
res.status(statusCode).json({ message: responseMessage });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 VNC 会话的 Guacamole 令牌 (通过调用 Guacamole 服务)
|
||||
* GET /api/v1/connections/:id/vnc-session
|
||||
*/
|
||||
export const getVncSessionToken = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const connectionId = parseInt(req.params.id, 10);
|
||||
if (isNaN(connectionId)) {
|
||||
res.status(400).json({ message: '无效的连接 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
|
||||
|
||||
if (!connectionData) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { connection, decryptedPassword } = connectionData;
|
||||
|
||||
if (connection.type !== 'VNC') {
|
||||
res.status(400).json({ message: '此连接类型不是 VNC。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
||||
await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds);
|
||||
console.log(`[Controller:getVncSessionToken] 已更新 VNC 连接 ${connectionId} 的 last_connected_at 为 ${currentTimeSeconds}`);
|
||||
} catch (updateError) {
|
||||
console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError);
|
||||
}
|
||||
|
||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||
console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`);
|
||||
res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = req.query;
|
||||
const initialWidth = width ? parseInt(width as string, 10) : undefined;
|
||||
const initialHeight = height ? parseInt(height as string, 10) : undefined;
|
||||
|
||||
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('vnc', connection, decryptedPassword, initialWidth, initialHeight);
|
||||
|
||||
console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId} with size ${initialWidth}x${initialHeight}`);
|
||||
|
||||
res.status(200).json({ token: guacamoleToken });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
|
||||
|
||||
let statusCode = 500;
|
||||
let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。';
|
||||
|
||||
if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (VNC)')) {
|
||||
responseMessage = error.message;
|
||||
if (error.message.includes('(状态: 4')) statusCode = 400;
|
||||
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
||||
else statusCode = 503;
|
||||
} else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('VNC 连接使用密码认证,但密码解密失败或未提供密码')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
} else if (error.message.includes('连接类型必须是 VNC')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
}
|
||||
else if (axios.isAxiosError(error)) {
|
||||
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
|
||||
if (error.response) {
|
||||
console.error('[Controller:getVncSessionToken] Remote Gateway error response:', error.response.data);
|
||||
responseMessage += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||
} else if (error.request) {
|
||||
console.error('[Controller:getVncSessionToken] No response from Remote Gateway.');
|
||||
responseMessage += ' (无法连接或超时)';
|
||||
statusCode = 504;
|
||||
}
|
||||
} else if (error.message.includes('解密失败')) {
|
||||
responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
}
|
||||
res.status(statusCode).json({ message: responseMessage });
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 克隆连接 (POST /api/v1/connections/:id/clone)
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
exportConnections,
|
||||
importConnections,
|
||||
getRdpSessionToken, // Import the new controller function
|
||||
getVncSessionToken, // Import the VNC session token controller function
|
||||
cloneConnection, // +++ Import the clone controller function +++
|
||||
// updateConnectionTags, // No longer directly used by primary flow
|
||||
addTagToConnections // +++ Import the new controller function for adding tag to multiple connections +++
|
||||
@@ -83,6 +84,9 @@ router.post('/test-unsaved', testUnsavedConnection);
|
||||
// POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend
|
||||
router.post('/:id/rdp-session', getRdpSessionToken);
|
||||
|
||||
// POST /api/v1/connections/:id/vnc-session - Get VNC session token
|
||||
router.post('/:id/vnc-session', getVncSessionToken);
|
||||
|
||||
// +++ POST /api/v1/connections/:id/clone - 克隆连接 +++
|
||||
router.post('/:id/clone', cloneConnection);
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ const columnExists = async (db: Database, tableName: string, columnName: string)
|
||||
});
|
||||
};
|
||||
|
||||
// 辅助函数:获取表的创建 SQL
|
||||
const getTableCreateSQL = async (db: Database, tableName: string): Promise<string | null> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", [tableName], (err, row: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.sql : null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const definedMigrations: Migration[] = [
|
||||
{
|
||||
@@ -121,6 +131,102 @@ const definedMigrations: Migration[] = [
|
||||
ALTER TABLE connections ADD COLUMN notes TEXT NULL;
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Update connections table to allow VNC type in CHECK constraint',
|
||||
check: async (db: Database): Promise<boolean> => {
|
||||
const createSQL = await getTableCreateSQL(db, 'connections');
|
||||
if (createSQL) {
|
||||
// 检查 CHECK 约束是否已经包含了 VNC
|
||||
// 这会检查 'VNC' 是否是允许的类型之一
|
||||
// 例如: CHECK(type IN ('SSH', 'RDP', 'VNC'))
|
||||
const constraintRegex = /CHECK\s*\(\s*LOWER\(type\)\s+IN\s*\(([^)]+)\)\s*\)/i; // 兼容大小写不敏感的检查
|
||||
const constraintRegexStrict = /CHECK\s*\(\s*type\s+IN\s*\(([^)]+)\)\s*\)/i;
|
||||
|
||||
let match = createSQL.match(constraintRegex);
|
||||
if (!match) {
|
||||
match = createSQL.match(constraintRegexStrict);
|
||||
}
|
||||
|
||||
if (match && match[1]) {
|
||||
const allowedTypes = match[1].split(',').map(t => t.trim().replace(/'/g, "").toLowerCase());
|
||||
return !allowedTypes.includes('vnc'); // 如果 'vnc' 不在允许类型中,则需要运行迁移
|
||||
}
|
||||
// 如果没有找到明确的 CHECK 约束或格式不匹配,保守地运行迁移
|
||||
console.warn('[Migrations] Check for VNC in connections.type: Could not parse CHECK constraint from SQL. Assuming migration is needed.');
|
||||
return true;
|
||||
}
|
||||
console.warn('[Migrations] Check for VNC in connections.type: Could not get table create SQL. Assuming migration is needed.');
|
||||
return true; // 如果表不存在或无法获取 SQL,则运行迁移
|
||||
},
|
||||
sql: `
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
-- 步骤 1: 重命名旧表
|
||||
ALTER TABLE connections RENAME TO connections_old_for_vnc_constraint_update;
|
||||
ALTER TABLE connection_tags RENAME TO connection_tags_old_for_vnc_constraint_update;
|
||||
|
||||
-- 步骤 2: 创建新表 (与 schema.ts 中的定义一致)
|
||||
CREATE TABLE connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
|
||||
encrypted_password TEXT NULL,
|
||||
encrypted_private_key TEXT NULL,
|
||||
encrypted_passphrase TEXT NULL,
|
||||
proxy_id INTEGER NULL,
|
||||
ssh_key_id INTEGER NULL,
|
||||
notes TEXT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
last_connected_at INTEGER NULL,
|
||||
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE connection_tags (
|
||||
connection_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (connection_id, tag_id),
|
||||
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 步骤 3: 从旧表复制数据到新表
|
||||
INSERT INTO connections (
|
||||
id, name, type, host, port, username, auth_method,
|
||||
encrypted_password, encrypted_private_key, encrypted_passphrase,
|
||||
proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at
|
||||
)
|
||||
SELECT
|
||||
id, name,
|
||||
CASE
|
||||
WHEN UPPER(type) = 'RDP' THEN 'RDP'
|
||||
WHEN UPPER(type) = 'SSH' THEN 'SSH'
|
||||
WHEN UPPER(type) = 'VNC' THEN 'VNC'
|
||||
ELSE 'SSH'
|
||||
END,
|
||||
host, port, username, auth_method,
|
||||
encrypted_password, encrypted_private_key, encrypted_passphrase,
|
||||
proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at
|
||||
FROM connections_old_for_vnc_constraint_update;
|
||||
|
||||
INSERT INTO connection_tags (connection_id, tag_id)
|
||||
SELECT connection_id, tag_id FROM connection_tags_old_for_vnc_constraint_update;
|
||||
|
||||
-- 步骤 4: 删除旧表
|
||||
DROP TABLE connections_old_for_vnc_constraint_update;
|
||||
DROP TABLE connection_tags_old_for_vnc_constraint_update;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
ANALYZE; -- 重新分析数据库模式
|
||||
`
|
||||
},
|
||||
// --- 未来可以添加更多迁移 ---
|
||||
];
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const createConnectionsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NULL, -- 允许 name 为空
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP')) DEFAULT 'SSH',
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs'; // fs is needed for early env loading if data/.env is checked
|
||||
|
||||
// --- 开始环境变量的早期加载 ---
|
||||
// 1. 加载根目录的 .env 文件 (定义部署模式等)
|
||||
// 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录
|
||||
const projectRootEnvPath = path.resolve(__dirname, '../../../.env');
|
||||
const rootConfigResult = dotenv.config({ path: projectRootEnvPath });
|
||||
|
||||
if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`[ENV Init Early] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`);
|
||||
} else if (!rootConfigResult.error) {
|
||||
console.log(`[ENV Init Early] Loaded environment variables from root .env file: ${projectRootEnvPath}`);
|
||||
} else {
|
||||
console.log(`[ENV Init Early] Root .env file not found at ${projectRootEnvPath}, proceeding without it.`);
|
||||
}
|
||||
|
||||
// 2. 加载 data/.env 文件 (定义密钥等)
|
||||
// 注意: 这个路径是相对于编译后的 dist/src/index.js
|
||||
const dataEnvPathGlobal = path.resolve(__dirname, '../data/.env'); // Renamed to avoid conflict if 'dataEnvPath' is used later
|
||||
const dataConfigResultGlobal = dotenv.config({ path: dataEnvPathGlobal }); // Renamed
|
||||
|
||||
if (dataConfigResultGlobal.error && (dataConfigResultGlobal.error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`[ENV Init Early] Warning: Could not load data .env file from ${dataEnvPathGlobal}. Error: ${dataConfigResultGlobal.error.message}`);
|
||||
} else if (!dataConfigResultGlobal.error) {
|
||||
console.log(`[ENV Init Early] Loaded environment variables from data .env file: ${dataEnvPathGlobal}`);
|
||||
}
|
||||
// --- 结束环境变量的早期加载 ---
|
||||
|
||||
import express = require('express');
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
// import fs from 'fs'; // Moved up
|
||||
// import path from 'path'; // Moved up
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
// import dotenv from 'dotenv'; // Moved up
|
||||
import session from 'express-session';
|
||||
import sessionFileStore from 'session-file-store';
|
||||
import { getDbInstance } from './database/connection';
|
||||
@@ -54,38 +84,16 @@ process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
||||
|
||||
|
||||
const initializeEnvironment = async () => {
|
||||
// 1. 加载根目录的 .env 文件 (定义部署模式等)
|
||||
// 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录
|
||||
const projectRootEnvPath = path.resolve(__dirname, '../../../.env');
|
||||
const rootConfigResult = dotenv.config({ path: projectRootEnvPath });
|
||||
// Use type assertion for error code checking
|
||||
if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// 只在文件存在但无法加载时发出警告
|
||||
console.warn(`[ENV Init] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`);
|
||||
} else if (!rootConfigResult.error) {
|
||||
console.log(`[ENV Init] Loaded environment variables from root .env file: ${projectRootEnvPath}`);
|
||||
} else {
|
||||
console.log(`[ENV Init] Root .env file not found at ${projectRootEnvPath}, proceeding without it (expected in non-local deployments where env vars are injected).`);
|
||||
}
|
||||
// Env files (root and data/.env) are now loaded at the very top of the file.
|
||||
// This function will now focus on generating keys if they are missing
|
||||
// and setting defaults for GUACD variables.
|
||||
|
||||
// 2. 加载 data/.env 文件 (定义密钥等)
|
||||
// 注意: 这个路径是相对于编译后的 dist/src/index.js
|
||||
const dataEnvPath = path.resolve(__dirname, '../data/.env');
|
||||
// Use the globally defined path for data .env
|
||||
const dataEnvPath = dataEnvPathGlobal; // Use the path defined at the top
|
||||
let keysGenerated = false;
|
||||
let keysToAppend = '';
|
||||
|
||||
// dotenv.config 默认不会覆盖已存在的 process.env 变量
|
||||
// 这意味着如果根 .env 和 data/.env 定义了相同的变量,先加载的(根 .env)的值会优先
|
||||
const dataConfigResult = dotenv.config({ path: dataEnvPath });
|
||||
// Use type assertion for error code checking
|
||||
if (dataConfigResult.error && (dataConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// 只在文件存在但无法加载时发出警告,文件不存在是正常情况
|
||||
console.warn(`[ENV Init] Warning: Could not load data .env file from ${dataEnvPath}. Error: ${dataConfigResult.error.message}`);
|
||||
} else if (!dataConfigResult.error) {
|
||||
console.log(`[ENV Init] Loaded environment variables from data .env file: ${dataEnvPath}`);
|
||||
}
|
||||
|
||||
// 2. 检查 ENCRYPTION_KEY
|
||||
// 检查 ENCRYPTION_KEY (process.env should be populated by early loading)
|
||||
if (!process.env.ENCRYPTION_KEY) {
|
||||
console.log('[ENV Init] ENCRYPTION_KEY 未设置,正在生成...');
|
||||
const newEncryptionKey = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
||||
interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP'; // Add type field
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Add type field
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
|
||||
@@ -43,9 +43,9 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
|
||||
// 1. 验证输入 (包含 type)
|
||||
// Convert type to uppercase for validation and consistency
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined; // Ensure type safety
|
||||
if (!connectionType || !['SSH', 'RDP'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH 或 RDP)。');
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
|
||||
if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。');
|
||||
}
|
||||
if (!input.host || !input.username) {
|
||||
throw new Error('缺少必要的连接信息 (host, username)。');
|
||||
@@ -70,6 +70,18 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
throw new Error('RDP 连接需要提供 password。');
|
||||
}
|
||||
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
|
||||
} else if (connectionType === 'VNC') {
|
||||
if (!input.password) {
|
||||
throw new Error('VNC 连接需要提供 password。');
|
||||
}
|
||||
// For VNC, auth_method is implicitly 'password'.
|
||||
// ssh_key_id, private_key, passphrase are not applicable.
|
||||
if (input.auth_method && input.auth_method !== 'password') {
|
||||
throw new Error('VNC 连接的认证方式必须是 password。');
|
||||
}
|
||||
if (input.ssh_key_id || input.private_key) {
|
||||
throw new Error('VNC 连接不支持 SSH 密钥认证。');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理凭证和 ssh_key_id (根据 type)
|
||||
@@ -108,16 +120,27 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。');
|
||||
}
|
||||
}
|
||||
} else { // RDP (connectionType is 'RDP')
|
||||
} else if (connectionType === 'RDP') { // RDP
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
// authMethodForDb remains 'password' for RDP to satisfy DB constraint
|
||||
// Ensure SSH specific fields are null for RDP
|
||||
// authMethodForDb remains 'password' for RDP
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
} else { // VNC
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
authMethodForDb = 'password'; // VNC always uses password auth
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
}
|
||||
|
||||
// 3. 准备仓库数据
|
||||
const defaultPort = input.type === 'RDP' ? 3389 : 22;
|
||||
let defaultPort = 22; // Default for SSH
|
||||
if (connectionType === 'RDP') {
|
||||
defaultPort = 3389;
|
||||
} else if (connectionType === 'VNC') {
|
||||
defaultPort = 5900; // Default VNC port
|
||||
}
|
||||
// +++ Explicitly type connectionData using the local alias +++
|
||||
const connectionData: ConnectionDataForRepo = {
|
||||
name: input.name || '',
|
||||
@@ -178,7 +201,7 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
// Determine the final type, converting input type to uppercase if provided
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined || currentFullConnection.type;
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
|
||||
|
||||
// 更新非凭证字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || '';
|
||||
@@ -280,20 +303,32 @@ if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes up
|
||||
dataToUpdate.encrypted_password = null;
|
||||
}
|
||||
}
|
||||
} else { // targetType is 'RDP'
|
||||
} else if (targetType === 'RDP') { // targetType is 'RDP'
|
||||
// RDP only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate) {
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id
|
||||
}
|
||||
} else { // targetType is 'VNC'
|
||||
// VNC only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to VNC or updating VNC
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果有更改,则更新连接记录
|
||||
|
||||
@@ -1 +1,93 @@
|
||||
// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package.
|
||||
import axios from 'axios';
|
||||
import { ConnectionWithTags } from '../types/connection.types';
|
||||
|
||||
// 统一远程桌面网关服务的 Base URL
|
||||
const REMOTE_GATEWAY_API_BASE = process.env.DEPLOYMENT_MODE === 'local'
|
||||
? process.env.REMOTE_GATEWAY_API_BASE_LOCAL || 'http://localhost:9090'
|
||||
: process.env.REMOTE_GATEWAY_API_BASE_DOCKER || 'http://remote-gateway:9090';
|
||||
|
||||
console.log(`[GuacamoleService] DEPLOYMENT_MODE: ${process.env.DEPLOYMENT_MODE}`);
|
||||
console.log(`[GuacamoleService] Using Remote Gateway API Base (Local): ${process.env.REMOTE_GATEWAY_API_BASE_LOCAL}`);
|
||||
console.log(`[GuacamoleService] Using Remote Gateway API Base (Docker): ${process.env.REMOTE_GATEWAY_API_BASE_DOCKER}`);
|
||||
console.log(`[GuacamoleService] Effective Remote Gateway API Base: ${REMOTE_GATEWAY_API_BASE}`);
|
||||
|
||||
interface TokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从统一远程桌面网关服务获取 Guacamole 令牌
|
||||
* @param protocol 'rdp' 或 'vnc'
|
||||
* @param connection 连接对象
|
||||
* @param decryptedPassword 解密后的密码
|
||||
* @param width 宽度
|
||||
* @param height 高度
|
||||
* @param dpi DPI (主要用于 RDP)
|
||||
* @returns Guacamole 令牌
|
||||
*/
|
||||
export const getRemoteDesktopToken = async (
|
||||
protocol: 'rdp' | 'vnc',
|
||||
connection: ConnectionWithTags,
|
||||
decryptedPassword?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
dpi?: string // DPI 主要用于 RDP
|
||||
): Promise<string> => {
|
||||
if ((protocol === 'rdp' || protocol === 'vnc') && connection.auth_method === 'password' && !decryptedPassword) {
|
||||
console.warn(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} connection ${connection.id} uses password auth but password decryption failed or password not provided.`);
|
||||
throw new Error(`${protocol.toUpperCase()} 连接使用密码认证,但密码解密失败或未提供密码。`);
|
||||
}
|
||||
|
||||
const connectionConfig: any = {
|
||||
hostname: connection.host,
|
||||
port: connection.port.toString(),
|
||||
width: String(width || 1024), // 提供默认值
|
||||
height: String(height || 768), // 提供默认值
|
||||
};
|
||||
|
||||
if (protocol === 'rdp') {
|
||||
if (!connection.username) {
|
||||
console.warn(`[GuacamoleService:getRemoteDesktopToken] RDP connection ${connection.id} is missing username.`);
|
||||
// 对于RDP,用户名通常是必需的,但让网关决定是否可以为空
|
||||
}
|
||||
connectionConfig.username = connection.username || ''; // RDP 通常需要用户名
|
||||
connectionConfig.password = decryptedPassword || ''; // RDP 通常需要密码
|
||||
connectionConfig.dpi = dpi || '96';
|
||||
connectionConfig.security = (connection as any).rdp_security || 'any';
|
||||
connectionConfig.ignoreCert = String((connection as any).rdp_ignore_cert ?? true);
|
||||
} else if (protocol === 'vnc') {
|
||||
connectionConfig.password = decryptedPassword || ''; // VNC 通常需要密码
|
||||
if (connection.username) { // VNC 用户名是可选的
|
||||
connectionConfig.username = connection.username;
|
||||
}
|
||||
// 其他 VNC 特定参数可以从 connection.extras 获取
|
||||
// 例如: if (connection.extras?.enableAudio) connectionConfig.enableAudio = connection.extras.enableAudio;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
protocol,
|
||||
connectionConfig
|
||||
};
|
||||
|
||||
const tokenUrl = `${REMOTE_GATEWAY_API_BASE}/api/remote-desktop/token`;
|
||||
console.log(`[GuacamoleService:getRemoteDesktopToken] Calling Remote Gateway API: ${tokenUrl} for protocol ${protocol}, connection ${connection.id}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post<TokenResponse>(tokenUrl, requestBody, {
|
||||
timeout: 10000 // 10 秒超时
|
||||
});
|
||||
|
||||
if (response.status !== 200 || !response.data?.token) {
|
||||
console.error(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} backend API call failed or returned invalid data. Status: ${response.status}`, response.data);
|
||||
throw new Error(`从 ${protocol.toUpperCase()} 后端获取令牌失败。`);
|
||||
}
|
||||
console.log(`[GuacamoleService:getRemoteDesktopToken] Received Guacamole token from ${protocol.toUpperCase()} backend for connection ${connection.id}`);
|
||||
return response.data.token;
|
||||
} catch (error: any) {
|
||||
console.error(`[GuacamoleService:getRemoteDesktopToken] Error calling ${protocol.toUpperCase()} backend for connection ${connection.id}:`, error.message);
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
|
||||
}
|
||||
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务时发生错误: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
||||
|
||||
interface ImportedConnectionData {
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP'; // Add type field
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Add type field
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -158,7 +158,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
try {
|
||||
|
||||
// Validate imported data, including type
|
||||
if (!connData.type || !['SSH', 'RDP'].includes(connData.type)) {
|
||||
if (!connData.type || !['SSH', 'RDP', 'VNC'].includes(connData.type)) {
|
||||
throw new Error('缺少或无效的连接类型 (type)。');
|
||||
}
|
||||
if (!connData.name || !connData.host || !connData.port || !connData.username) {
|
||||
@@ -205,7 +205,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
}
|
||||
|
||||
// Prepare data for repository, ensuring correct auth_method for RDP
|
||||
const authMethodForDb = connData.type === 'RDP' ? 'password' : connData.auth_method!;
|
||||
const authMethodForDb = (connData.type === 'RDP' || connData.type === 'VNC') ? 'password' : connData.auth_method!;
|
||||
connectionsToInsert.push({
|
||||
name: connData.name,
|
||||
type: connData.type, // Add type
|
||||
|
||||
@@ -47,6 +47,8 @@ export const settingsController = {
|
||||
'timezone', // NEW: 添加时区键
|
||||
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
|
||||
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
|
||||
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
|
||||
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
|
||||
'ipBlacklistEnabled', // <-- 添加 IP 黑名单启用键
|
||||
'layoutLocked', // +++ 添加布局锁定键 +++
|
||||
'terminalScrollbackLimit', // NEW: 添加终端回滚行数键
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -22,7 +22,7 @@ export interface ConnectionWithTags extends ConnectionBase {
|
||||
|
||||
export interface CreateConnectionInput {
|
||||
name?: string;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
@@ -39,7 +39,7 @@ notes?: string | null; // 新增备注字段
|
||||
|
||||
export interface UpdateConnectionInput {
|
||||
name?: string;
|
||||
type?: 'SSH' | 'RDP';
|
||||
type?: 'SSH' | 'RDP' | 'VNC';
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
@@ -57,7 +57,7 @@ notes?: string | null; // 新增备注字段
|
||||
export interface FullConnectionData {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
|
||||
@@ -530,26 +530,27 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
|
||||
|
||||
// Determine RDP target URL based on deployment mode
|
||||
const deploymentMode = process.env.DEPLOYMENT_MODE; // Default to docker mode
|
||||
let rdpBaseUrl: string;
|
||||
const deploymentMode = process.env.DEPLOYMENT_MODE;
|
||||
let remoteGatewayWsBaseUrl: string;
|
||||
if (deploymentMode === 'local') {
|
||||
rdpBaseUrl = process.env.RDP_SERVICE_URL_LOCAL || 'ws://localhost:8081'; // Default for local, fallback to localhost:3001
|
||||
console.log(`[WebSocket RDP Proxy] Using LOCAL deployment mode. RDP Target Base: ${rdpBaseUrl}`);
|
||||
} else if (deploymentMode === 'docker') { // Explicitly check for docker mode
|
||||
rdpBaseUrl = process.env.RDP_SERVICE_URL_DOCKER || 'ws://rdp:8081'; // Default for docker, fallback to localhost:3001
|
||||
console.log(`[WebSocket RDP Proxy] Using DOCKER deployment mode. RDP Target Base: ${rdpBaseUrl}`);
|
||||
} else { // Handle unknown modes
|
||||
rdpBaseUrl = 'ws://localhost:8081'; // Fallback to a safe default for unknown modes
|
||||
console.warn(`[WebSocket RDP Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback RDP Target Base: ${rdpBaseUrl}`);
|
||||
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_LOCAL || 'ws://localhost:8080'; // 更新端口和环境变量名
|
||||
console.log(`[WebSocket Remote Desktop Proxy] Using LOCAL deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||
} else if (deploymentMode === 'docker') {
|
||||
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_DOCKER || 'ws://remote-gateway:8080'; // 更新服务名、端口和环境变量名
|
||||
console.log(`[WebSocket Remote Desktop Proxy] Using DOCKER deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||
} else {
|
||||
remoteGatewayWsBaseUrl = 'ws://localhost:8080'; // 更新默认端口
|
||||
console.warn(`[WebSocket Remote Desktop Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||
}
|
||||
|
||||
const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl;
|
||||
const cleanRemoteGatewayWsBaseUrl = remoteGatewayWsBaseUrl.endsWith('/') ? remoteGatewayWsBaseUrl.slice(0, -1) : remoteGatewayWsBaseUrl;
|
||||
|
||||
const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`; // 使用 calculatedDpi
|
||||
// 构建目标 URL 时,协议 (RDP/VNC) 信息现在由 remote-gateway 处理,我们只需要传递令牌和尺寸
|
||||
const remoteDesktopTargetUrl = `${cleanRemoteGatewayWsBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`;
|
||||
|
||||
console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`);
|
||||
console.log(`WebSocket: Remote Desktop Proxy for ${ws.username} attempting to connect to ${remoteDesktopTargetUrl}`);
|
||||
|
||||
const rdpWs = new WebSocket(rdpTargetUrl);
|
||||
const rdpWs = new WebSocket(remoteDesktopTargetUrl);
|
||||
let clientWsClosed = false;
|
||||
let rdpWsClosed = false;
|
||||
|
||||
@@ -586,7 +587,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
});
|
||||
rdpWs.on('error', (error) => {
|
||||
|
||||
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error);
|
||||
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${remoteDesktopTargetUrl} 时出错:`, error);
|
||||
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||
console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`);
|
||||
ws.close(1011, `RDP WS Error: ${error.message}`);
|
||||
@@ -610,7 +611,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
rdpWs.on('close', (code, reason) => {
|
||||
rdpWsClosed = true;
|
||||
// --- 添加中文日志 ---
|
||||
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
|
||||
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
|
||||
// --- 结束日志 ---
|
||||
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||
console.log(`[RDP 代理] 因 RDP WS 关闭而关闭客户端 WS。会话: ${ws.sessionId}`);
|
||||
@@ -620,7 +621,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
});
|
||||
|
||||
rdpWs.on('open', () => {
|
||||
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`);
|
||||
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已建立。开始转发消息。`);
|
||||
});
|
||||
|
||||
// --- 标准 (SSH/SFTP/Docker) 连接处理 ---
|
||||
|
||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
||||
# Copy workspace package.json files to ensure npm ci works correctly in monorepo
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/rdp/package.json ./packages/rdp/
|
||||
COPY packages/remote-gateway/package.json ./packages/remote-gateway/
|
||||
|
||||
# Install dependencies (using install instead of ci for potential armv7 compatibility issues)
|
||||
RUN npm install
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nexus-terminal/frontend",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -20,6 +20,8 @@ import StyleCustomizer from './components/StyleCustomizer.vue';
|
||||
import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue';
|
||||
// +++ 导入 RDP 模态框组件 +++
|
||||
import RemoteDesktopModal from './components/RemoteDesktopModal.vue';
|
||||
// +++ 导入 VNC 模态框组件 +++
|
||||
import VncModal from './components/VncModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
@@ -33,7 +35,7 @@ const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
|
||||
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
|
||||
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
|
||||
const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore);
|
||||
const { isRdpModalOpen, rdpConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 状态 +++
|
||||
const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 和 VNC 状态 +++
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind); // +++ Initialize Breakpoints +++
|
||||
const isMobile = breakpoints.smaller('md'); // +++ Define isMobile +++
|
||||
|
||||
@@ -319,6 +321,13 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
|
||||
@close="sessionStore.closeRdpModal()"
|
||||
/>
|
||||
|
||||
<!-- +++ 条件渲染 VNC 模态框 +++ -->
|
||||
<VncModal
|
||||
v-if="isVncModalOpen"
|
||||
:connection="vncConnectionInfo"
|
||||
@close="sessionStore.closeVncModal()"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const { isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshK
|
||||
|
||||
// 表单数据模型
|
||||
const initialFormData = {
|
||||
type: 'SSH' as 'SSH' | 'RDP', // Use uppercase to match ConnectionInfo
|
||||
type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
@@ -42,7 +42,8 @@ const initialFormData = {
|
||||
selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++
|
||||
proxy_id: null as number | null,
|
||||
tag_ids: [] as number[], // 新增 tag_ids 字段
|
||||
notes: '', // 新增备注字段
|
||||
notes: '', // 新增备注字段
|
||||
vncPassword: '', // VNC specific password
|
||||
// Add RDP specific fields later if needed, e.g., domain
|
||||
};
|
||||
const formData = reactive({ ...initialFormData });
|
||||
@@ -79,7 +80,7 @@ watch(() => props.connectionToEdit, (newVal) => {
|
||||
formError.value = null; // 清除错误
|
||||
if (newVal) {
|
||||
// 编辑模式:填充表单,但不填充敏感信息
|
||||
formData.type = newVal.type; // Correctly set the type for editing
|
||||
formData.type = newVal.type as 'SSH' | 'RDP' | 'VNC'; // Correctly set the type for editing
|
||||
formData.name = newVal.name;
|
||||
formData.host = newVal.host;
|
||||
formData.port = newVal.port;
|
||||
@@ -90,23 +91,34 @@ formData.notes = newVal.notes ?? ''; // 填充备注
|
||||
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // 填充 tag_ids (深拷贝)
|
||||
|
||||
// +++ 填充 selected_ssh_key_id (如果认证方式是 key) +++
|
||||
if (newVal.auth_method === 'key') {
|
||||
if (newVal.type === 'SSH' && newVal.auth_method === 'key') {
|
||||
formData.selected_ssh_key_id = newVal.ssh_key_id ?? null;
|
||||
} else {
|
||||
formData.selected_ssh_key_id = null; // 清空,以防之前是 key
|
||||
formData.selected_ssh_key_id = null; // 清空
|
||||
}
|
||||
|
||||
// 清空敏感字段
|
||||
formData.password = ''; // For SSH/RDP
|
||||
formData.private_key = '';
|
||||
formData.passphrase = '';
|
||||
// formData.vncPassword is already handled by initialFormData or cleared if not VNC
|
||||
if (newVal.type !== 'VNC') {
|
||||
formData.vncPassword = '';
|
||||
} else {
|
||||
// If editing a VNC connection, we don't get vncPassword from backend directly.
|
||||
// User needs to re-enter if they want to change it.
|
||||
// For display, it's kept empty.
|
||||
formData.vncPassword = '';
|
||||
}
|
||||
|
||||
// 清空敏感字段 (密码和直接输入的密钥)
|
||||
formData.password = '';
|
||||
formData.private_key = ''; // 即使是 key 认证,编辑时也不显示旧的直接输入密钥
|
||||
formData.passphrase = ''; // 同上
|
||||
|
||||
} else {
|
||||
// 添加模式:重置表单
|
||||
Object.assign(formData, initialFormData);
|
||||
formData.tag_ids = []; // 确保 tag_ids 也被重置为空数组
|
||||
formData.selected_ssh_key_id = null; // 确保添加模式下也重置
|
||||
formData.notes = ''; // 重置备注
|
||||
formData.notes = ''; // 重置备注
|
||||
formData.vncPassword = ''; // 重置VNC密码
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -120,15 +132,17 @@ onMounted(() => {
|
||||
// 监听连接类型变化,动态调整默认端口
|
||||
watch(() => formData.type, (newType) => {
|
||||
// Use uppercase for comparison
|
||||
if (newType === 'RDP' && formData.port === 22) {
|
||||
formData.port = 3389; // RDP 默认端口
|
||||
} else if (newType === 'SSH' && formData.port === 3389) {
|
||||
formData.port = 22; // SSH 默认端口
|
||||
}
|
||||
// 重置或调整认证方式等逻辑可以在这里添加
|
||||
if (newType === 'RDP') {
|
||||
// RDP 通常只用密码,可以强制或隐藏 auth_method
|
||||
// formData.auth_method = 'password'; // Example: Force password for RDP
|
||||
if (formData.port === 22 || formData.port === 5900 || formData.port === 5901) formData.port = 3389; // RDP 默认端口
|
||||
formData.auth_method = 'password'; // RDP uses password
|
||||
formData.selected_ssh_key_id = null; // Clear SSH key selection
|
||||
} else if (newType === 'SSH') {
|
||||
if (formData.port === 3389 || formData.port === 5900 || formData.port === 5901) formData.port = 22; // SSH 默认端口
|
||||
// auth_method will be handled by its own select
|
||||
} else if (newType === 'VNC') {
|
||||
if (formData.port === 22 || formData.port === 3389) formData.port = 5900; // VNC 默认端口 (e.g., 5900 or 5901)
|
||||
formData.auth_method = 'password'; // VNC uses password, hide auth_method selector
|
||||
formData.selected_ssh_key_id = null; // Clear SSH key selection
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,11 +204,18 @@ const handleSubmit = async () => {
|
||||
// RDP Validation
|
||||
// 1. 添加模式下,密码是必填的
|
||||
if (!isEditMode.value && !formData.password) {
|
||||
formError.value = t('connections.form.errorPasswordRequired'); // 可以复用密码必填的翻译
|
||||
formError.value = t('connections.form.errorPasswordRequired');
|
||||
return;
|
||||
}
|
||||
// 2. 编辑模式下,密码可以不填(表示不修改),除非是从非 RDP 类型切换过来(这个逻辑比较复杂,暂时简化为密码非必填)
|
||||
// 如果需要更严格的验证(例如从 SSH 编辑为 RDP 时强制要求输入密码),可以在这里添加
|
||||
// 2. 编辑模式下,密码可以不填(表示不修改)
|
||||
} else if (formData.type === 'VNC') {
|
||||
// VNC Validation
|
||||
// 1. 添加模式下,VNC密码是必填的
|
||||
if (!isEditMode.value && !formData.vncPassword) {
|
||||
formError.value = t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'); // Add new translation key
|
||||
return;
|
||||
}
|
||||
// 2. 编辑模式下,VNC密码可以不填(表示不修改)
|
||||
}
|
||||
// --- 验证逻辑结束 ---
|
||||
|
||||
@@ -256,6 +277,21 @@ notes: formData.notes, // 添加备注
|
||||
delete dataToSend.auth_method;
|
||||
delete dataToSend.private_key;
|
||||
delete dataToSend.passphrase;
|
||||
delete dataToSend.vncPassword; // Ensure VNC password field for form doesn't go if RDP
|
||||
} else if (formData.type === 'VNC') {
|
||||
// VNC data population
|
||||
if (formData.vncPassword) {
|
||||
dataToSend.password = formData.vncPassword; // Backend expects VNC password in 'password' field
|
||||
} else if (isEditMode.value && formData.vncPassword === '') {
|
||||
// Editing VNC, empty password means don't change
|
||||
}
|
||||
// VNC does not use SSH specific fields
|
||||
delete dataToSend.auth_method;
|
||||
delete dataToSend.private_key;
|
||||
delete dataToSend.passphrase;
|
||||
delete dataToSend.ssh_key_id;
|
||||
// formData.vncPassword is used for the form, but backend might expect it as 'password'
|
||||
// So, we don't send 'vncPassword' itself to backend.
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +460,7 @@ const testButtonText = computed(() => {
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||
<option value="SSH">{{ t('connections.form.typeSsh', 'SSH') }}</option>
|
||||
<option value="RDP">{{ t('connections.form.typeRdp', 'RDP') }}</option>
|
||||
<option value="VNC">{{ t('connections.form.typeVnc', 'VNC') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Host and Port Row -->
|
||||
@@ -498,13 +535,22 @@ const testButtonText = computed(() => {
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- VNC Specific Auth (Password only) -->
|
||||
<template v-if="formData.type === 'VNC'">
|
||||
<div>
|
||||
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
|
||||
<input type="password" id="conn-password-vnc" v-model="formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options Section -->
|
||||
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAdvanced', '高级选项') }}</h4>
|
||||
<div v-if="formData.type !== 'RDP'"> <!-- Proxy Select - Hide for RDP -->
|
||||
<div v-if="formData.type === 'SSH'"> <!-- Proxy Select - Show only for SSH -->
|
||||
<label for="conn-proxy" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.proxy') }} ({{ t('connections.form.optional') }})</label>
|
||||
<select id="conn-proxy" v-model="formData.proxy_id"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||
@@ -551,7 +597,6 @@ const testButtonText = computed(() => {
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
|
||||
<!-- Test Area (Only show for SSH) -->
|
||||
<!-- Use uppercase for comparison -->
|
||||
<div v-if="formData.type === 'SSH'" class="flex flex-col items-start gap-1">
|
||||
<div class="flex items-center gap-2"> <!-- Button and Icon -->
|
||||
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
|
||||
@@ -583,7 +628,7 @@ const testButtonText = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder for alignment when test button is hidden -->
|
||||
<div v-else class="flex-1"></div>
|
||||
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
|
||||
<div class="flex space-x-3"> <!-- Main Actions -->
|
||||
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||
|
||||
@@ -182,7 +182,7 @@ const handleDelete = async (conn: ConnectionInfo) => {
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="conn in groupConnections" :key="conn.id" class="hover:bg-hover transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap flex items-center">
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<i :class="['fas', conn.type === 'RDP' || conn.type === 'VNC' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.host }}</td>
|
||||
|
||||
@@ -287,10 +287,7 @@ const sidebarProps = computed(() => (paneName: PaneName | null, side: 'left' | '
|
||||
return {
|
||||
...baseProps,
|
||||
// Event forwarding
|
||||
onConnectRequest: (id: number) => {
|
||||
console.log(`[LayoutRenderer Sidebar] Forwarding 'connect-request' for ID: ${id}`);
|
||||
emit('connect-request', id);
|
||||
},
|
||||
onConnectRequest: (id: number) => emit('connect-request', id),
|
||||
onOpenNewSession: (id: number) => {
|
||||
console.log(`[LayoutRenderer Sidebar] Forwarding 'open-new-session' for ID: ${id}`);
|
||||
emit('open-new-session', id);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store'; // +++ Import connections store +++
|
||||
// @ts-ignore - guacamole-common-js 缺少官方类型定义
|
||||
import Guacamole from 'guacamole-common-js';
|
||||
import apiClient from '../utils/apiClient';
|
||||
@@ -24,81 +25,88 @@ const rdpDisplayRef = ref<HTMLDivElement | null>(null);
|
||||
const rdpContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const guacClient = ref<any | null>(null);
|
||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||
const isResizing = ref(false);
|
||||
const resizeStartX = ref(0);
|
||||
const resizeStartY = ref(0);
|
||||
const initialModalWidthForResize = ref(0); // Renamed to avoid conflict if other 'initialModalWidth' exists
|
||||
const initialModalHeightForResize = ref(0); // Renamed
|
||||
const statusMessage = ref('');
|
||||
const keyboard = ref<any | null>(null);
|
||||
const mouse = ref<any | null>(null);
|
||||
const desiredModalWidth = ref(1064);
|
||||
const desiredModalHeight = ref(858);
|
||||
const isKeyboardDisabledForInput = ref(false); // 标记键盘是否因输入框聚焦而禁用
|
||||
const isMinimized = ref(false);
|
||||
const restoreButtonRef = ref<HTMLButtonElement | null>(null);
|
||||
const isDraggingRestoreButton = ref(false);
|
||||
const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
let hasDragged = false; // 新增 hasDragged 标志
|
||||
|
||||
const MIN_MODAL_WIDTH = 1024;
|
||||
const MIN_MODAL_HEIGHT = 768;
|
||||
|
||||
// Dynamically construct WebSocket URL based on environment
|
||||
let backendBaseUrl: string;
|
||||
const LOCAL_BACKEND_URL = 'ws://localhost:3001'
|
||||
const LOCAL_BACKEND_URL = 'ws://localhost:3001'; // For RDP proxy via main backend
|
||||
|
||||
// Determine WebSocket URL based on hostname
|
||||
// Determine WebSocket URL based on hostname for RDP
|
||||
if (window.location.hostname === 'localhost') {
|
||||
backendBaseUrl = LOCAL_BACKEND_URL;
|
||||
console.log(`[RDP 模态框] 使用 localhost WebSocket 基础 URL: ${backendBaseUrl}`);
|
||||
} else {
|
||||
// 备选方案: 根据当前 window.location 为生产环境或其他环境构建 URL
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHostAndPort = window.location.host;
|
||||
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`;
|
||||
console.log(`[RDP 模态框] 使用生产环境 WebSocket 基础 URL (来自 window.location): ${backendBaseUrl}`);
|
||||
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`; // Assuming RDP proxy is at /ws path
|
||||
}
|
||||
|
||||
const connectRdp = async () => {
|
||||
const handleConnection = async () => {
|
||||
if (!props.connection || !rdpDisplayRef.value) {
|
||||
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
|
||||
connectionStatus.value = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous display and disconnect
|
||||
while (rdpDisplayRef.value.firstChild) {
|
||||
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
|
||||
}
|
||||
disconnectRdp();
|
||||
disconnectGuacamole(); // Renamed from disconnectRdp
|
||||
|
||||
connectionStatus.value = 'connecting';
|
||||
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
|
||||
|
||||
try {
|
||||
const apiUrl = `connections/${props.connection.id}/rdp-session`;
|
||||
let token: string | null = null;
|
||||
let tunnelUrl: string = '';
|
||||
const connectionsStore = useConnectionsStore();
|
||||
|
||||
const response = await apiClient.post<{ token: string }>(apiUrl);
|
||||
if (props.connection.type === 'RDP') {
|
||||
const apiUrl = `connections/${props.connection.id}/rdp-session`;
|
||||
const response = await apiClient.post<{ token: string }>(apiUrl);
|
||||
token = response.data?.token;
|
||||
if (!token) {
|
||||
throw new Error('RDP Token not found in API response');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||
|
||||
const token = response.data?.token;
|
||||
if (!token) {
|
||||
throw new Error('Token not found in API response');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||
await nextTick();
|
||||
let widthToSend = 800;
|
||||
let heightToSend = 600;
|
||||
const dpiToSend = 96;
|
||||
|
||||
// DOM 更新后获取 RDP 容器尺寸
|
||||
await nextTick();
|
||||
|
||||
let widthToSend = 800; // 默认/备用宽度
|
||||
let heightToSend = 600; // 默认/备用高度
|
||||
const dpiToSend = 96;
|
||||
|
||||
if (rdpContainerRef.value) {
|
||||
// 使用 clientWidth/clientHeight,因为它们代表可用于内容的内部尺寸
|
||||
if (rdpContainerRef.value) {
|
||||
widthToSend = rdpContainerRef.value.clientWidth;
|
||||
heightToSend = rdpContainerRef.value.clientHeight - 1; // 根据反馈减去 1
|
||||
// 确保最小尺寸,必要时根据后端要求进行调整
|
||||
heightToSend = rdpContainerRef.value.clientHeight - 1;
|
||||
widthToSend = Math.max(100, widthToSend);
|
||||
heightToSend = Math.max(100, heightToSend);
|
||||
console.log(`计算出的 RDP 尺寸: ${widthToSend}x${heightToSend}`);
|
||||
}
|
||||
tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
|
||||
|
||||
} else {
|
||||
console.warn("RDP 容器引用不可用,无法获取尺寸。使用默认值。");
|
||||
// 考虑设置错误状态或通知用户
|
||||
throw new Error(`Unsupported connection type: ${props.connection.type}`);
|
||||
}
|
||||
|
||||
// 使用确定的基础 URL 构建后端代理端点的 URL
|
||||
const tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
|
||||
console.log(`[RDP 模态框] 连接到隧道: ${tunnelUrl}`); // 记录最终 URL
|
||||
// @ts-ignore
|
||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||
|
||||
@@ -107,82 +115,101 @@ const connectRdp = async () => {
|
||||
const errorCode = status.code || 'N/A';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
guacClient.value = new Guacamole.Client(tunnel);
|
||||
// 添加此行以启用 keep-alive (每 3 秒发送 NOP)
|
||||
guacClient.value.keepAliveFrequency = 3000; // 毫秒
|
||||
guacClient.value.keepAliveFrequency = 3000;
|
||||
|
||||
rdpDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
|
||||
|
||||
guacClient.value.onstatechange = (state: number) => {
|
||||
let currentStatus = '';
|
||||
let i18nKeyPart = 'unknownState';
|
||||
|
||||
switch (state) {
|
||||
case 0:
|
||||
statusMessage.value = t('remoteDesktopModal.status.idle');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 0: // IDLE
|
||||
i18nKeyPart = 'idle';
|
||||
currentStatus = 'disconnected';
|
||||
break;
|
||||
case 1:
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingRdp');
|
||||
connectionStatus.value = 'connecting';
|
||||
case 1: // CONNECTING
|
||||
i18nKeyPart = 'connectingRdp';
|
||||
currentStatus = 'connecting';
|
||||
break;
|
||||
case 2:
|
||||
statusMessage.value = t('remoteDesktopModal.status.waiting');
|
||||
connectionStatus.value = 'connecting';
|
||||
case 2: // WAITING
|
||||
i18nKeyPart = 'waiting';
|
||||
currentStatus = 'connecting';
|
||||
break;
|
||||
case 3:
|
||||
statusMessage.value = t('remoteDesktopModal.status.connected');
|
||||
connectionStatus.value = 'connected';
|
||||
case 3: // CONNECTED
|
||||
i18nKeyPart = 'connected';
|
||||
currentStatus = 'connected';
|
||||
setupInputListeners();
|
||||
// 连接成功后,尝试将焦点设置到 RDP 显示区域
|
||||
nextTick(() => {
|
||||
const displayEl = guacClient.value?.getDisplay()?.getElement();
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
console.log('[RDP Modal] Focused RDP display after connection.');
|
||||
} else {
|
||||
console.warn('[RDP Modal] Could not focus RDP display after connection.');
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(() => { // z-index fix for canvas
|
||||
nextTick(() => {
|
||||
if (rdpDisplayRef.value && guacClient.value) {
|
||||
const canvases = rdpDisplayRef.value.querySelectorAll('canvas');
|
||||
canvases.forEach((canvas) => {
|
||||
canvas.style.zIndex = '999';
|
||||
});
|
||||
canvases.forEach((canvas) => { canvas.style.zIndex = '999'; });
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
break;
|
||||
case 4:
|
||||
statusMessage.value = t('remoteDesktopModal.status.disconnecting');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 4: // DISCONNECTING
|
||||
i18nKeyPart = 'disconnecting';
|
||||
currentStatus = 'disconnected'; // Or 'disconnecting'
|
||||
break;
|
||||
case 5:
|
||||
statusMessage.value = t('remoteDesktopModal.status.disconnected');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 5: // DISCONNECTED
|
||||
i18nKeyPart = 'disconnected';
|
||||
currentStatus = 'disconnected';
|
||||
break;
|
||||
default:
|
||||
statusMessage.value = `${t('remoteDesktopModal.status.unknownState')}: ${state}`;
|
||||
}
|
||||
statusMessage.value = t(`remoteDesktopModal.status.${i18nKeyPart}`, { state });
|
||||
if (currentStatus) connectionStatus.value = currentStatus as 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
};
|
||||
|
||||
guacClient.value.onerror = (status: any) => {
|
||||
const errorMessage = status.message || 'Unknown client error';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
guacClient.value.connect(''); // 保留 '' 的更改
|
||||
guacClient.value.connect('');
|
||||
|
||||
} catch (error: any) {
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
}
|
||||
};
|
||||
|
||||
const trySyncClipboardOnDisplayFocus = async () => {
|
||||
if (!guacClient.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const currentClipboardText = await navigator.clipboard.readText();
|
||||
if (currentClipboardText && guacClient.value) {
|
||||
// @ts-ignore
|
||||
const stream = guacClient.value.createClipboardStream('text/plain');
|
||||
// @ts-ignore
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(currentClipboardText);
|
||||
writer.sendEnd();
|
||||
console.log('[RemoteDesktopModal] Sent clipboard to RDP on display focus:', currentClipboardText.substring(0, 50) + (currentClipboardText.length > 50 ? '...' : ''));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
// console.log('[RemoteDesktopModal] Clipboard read on display focus skipped: Document not focused or permission denied.');
|
||||
} else {
|
||||
console.warn('[RemoteDesktopModal] Could not read clipboard on display focus, or other error:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,6 +227,10 @@ const setupInputListeners = () => {
|
||||
activeElement.blur();
|
||||
console.log('[RDP Modal] Blurred input field on RDP display click.');
|
||||
}
|
||||
// Ensure the RDP display element gets focus when clicked
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
}
|
||||
};
|
||||
displayEl.addEventListener('click', handleRdpDisplayClick);
|
||||
|
||||
@@ -263,6 +294,9 @@ const setupInputListeners = () => {
|
||||
guacClient.value.sendKeyEvent(0, keysym);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for display focus to sync clipboard
|
||||
displayEl.addEventListener('focus', trySyncClipboardOnDisplayFocus);
|
||||
|
||||
} catch (inputError) {
|
||||
console.error("Error setting up input listeners:", inputError); // 添加错误日志
|
||||
@@ -278,6 +312,7 @@ const removeInputListeners = () => {
|
||||
if (displayEl) {
|
||||
// 恢复默认光标样式
|
||||
displayEl.style.cursor = 'default';
|
||||
displayEl.removeEventListener('focus', trySyncClipboardOnDisplayFocus);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not reset cursor or remove listeners on display element during listener removal:", e);
|
||||
@@ -315,7 +350,56 @@ const enableRdpKeyboard = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const disconnectRdp = () => {
|
||||
const minimizeModal = () => {
|
||||
isMinimized.value = true;
|
||||
};
|
||||
|
||||
const restoreModal = () => {
|
||||
isMinimized.value = false;
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseDown = (event: MouseEvent) => {
|
||||
if (!restoreButtonRef.value) return;
|
||||
hasDragged = false; // 重置拖拽标志
|
||||
isDraggingRestoreButton.value = true;
|
||||
dragOffsetX = event.clientX - restoreButtonRef.value.getBoundingClientRect().left;
|
||||
dragOffsetY = event.clientY - restoreButtonRef.value.getBoundingClientRect().top;
|
||||
event.preventDefault();
|
||||
document.addEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.addEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseMove = (event: MouseEvent) => {
|
||||
if (!isDraggingRestoreButton.value) return;
|
||||
hasDragged = true; // 如果鼠标移动则设置拖拽标志
|
||||
let newX = event.clientX - dragOffsetX;
|
||||
let newY = event.clientY - dragOffsetY;
|
||||
|
||||
const buttonWidth = 50;
|
||||
const buttonHeight = 50;
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - buttonWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - buttonHeight));
|
||||
|
||||
restoreButtonPosition.value = { x: newX, y: newY };
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseUp = () => {
|
||||
isDraggingRestoreButton.value = false;
|
||||
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
// click 事件会在 mouseup 后触发。如果我们拖拽了,我们不希望 click 事件恢复模态框。
|
||||
// handleClickRestoreButton 会检查 hasDragged。
|
||||
};
|
||||
|
||||
const handleClickRestoreButton = () => {
|
||||
if (!hasDragged) {
|
||||
restoreModal();
|
||||
}
|
||||
// 为下一次交互重置
|
||||
hasDragged = false;
|
||||
};
|
||||
|
||||
const disconnectGuacamole = () => {
|
||||
removeInputListeners();
|
||||
isKeyboardDisabledForInput.value = false; // 确保状态重置
|
||||
if (guacClient.value) {
|
||||
@@ -335,7 +419,7 @@ const disconnectRdp = () => {
|
||||
|
||||
|
||||
const closeModal = () => {
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
@@ -405,13 +489,12 @@ watchEffect(() => {
|
||||
desiredModalHeight.value = finalHeight;
|
||||
});
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
// 初始尺寸加载现在由 watchEffect 处理
|
||||
|
||||
if (props.connection) {
|
||||
nextTick(async () => {
|
||||
await connectRdp(); // 使用初始尺寸连接
|
||||
await handleConnection(); // 使用初始尺寸连接
|
||||
// 不再需要设置 observer
|
||||
});
|
||||
} else {
|
||||
@@ -421,17 +504,24 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectRdp(); // 这里已经调用了 removeInputListeners
|
||||
disconnectGuacamole(); // 这里已经调用了 removeInputListeners
|
||||
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
// Clean up resize listeners if component is unmounted while resizing
|
||||
if (isResizing.value) {
|
||||
document.removeEventListener('mousemove', doResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.connection, (newConnection, oldConnection) => {
|
||||
if (newConnection && newConnection.id !== oldConnection?.id) {
|
||||
nextTick(async () => {
|
||||
await connectRdp(); // 使用初始尺寸连接
|
||||
await handleConnection(); // 使用初始尺寸连接
|
||||
// 不再需要设置 observer
|
||||
});
|
||||
} else if (!newConnection) {
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||
connectionStatus.value = 'error';
|
||||
}
|
||||
@@ -449,19 +539,90 @@ const computedModalStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// Watch for modal size changes to update Guacamole client
|
||||
watchEffect(() => {
|
||||
const currentStyle = computedModalStyle.value; // Dependency
|
||||
if (guacClient.value && connectionStatus.value === 'connected' && rdpContainerRef.value) {
|
||||
nextTick(() => {
|
||||
if (rdpContainerRef.value && guacClient.value) {
|
||||
const displayWidth = rdpContainerRef.value.offsetWidth;
|
||||
const displayHeight = rdpContainerRef.value.offsetHeight;
|
||||
if (displayWidth > 0 && displayHeight > 0) {
|
||||
// console.log(`[RDP Modal] Resizing Guacamole display to: ${displayWidth}x${displayHeight} due to style change.`);
|
||||
guacClient.value.sendSize(displayWidth, displayHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const initResize = (event: MouseEvent) => {
|
||||
isResizing.value = true;
|
||||
resizeStartX.value = event.clientX;
|
||||
resizeStartY.value = event.clientY;
|
||||
initialModalWidthForResize.value = desiredModalWidth.value;
|
||||
initialModalHeightForResize.value = desiredModalHeight.value;
|
||||
|
||||
document.addEventListener('mousemove', doResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const doResize = (event: MouseEvent) => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
const deltaX = event.clientX - resizeStartX.value;
|
||||
const deltaY = event.clientY - resizeStartY.value;
|
||||
|
||||
let newWidth = initialModalWidthForResize.value + deltaX;
|
||||
let newHeight = initialModalHeightForResize.value + deltaY;
|
||||
|
||||
newWidth = Math.max(MIN_MODAL_WIDTH, newWidth);
|
||||
newHeight = Math.max(MIN_MODAL_HEIGHT, newHeight);
|
||||
|
||||
desiredModalWidth.value = newWidth;
|
||||
desiredModalHeight.value = newHeight;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (!isResizing.value) return;
|
||||
isResizing.value = false;
|
||||
document.removeEventListener('mousemove', doResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
// Guacamole size update is handled by the watchEffect above
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4">
|
||||
<div
|
||||
:style="computedModalStyle"
|
||||
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
||||
isMinimized ? '' : 'bg-overlay',
|
||||
isMinimized ? 'pointer-events-none' : '' // 允许恢复按钮接收事件
|
||||
]"
|
||||
>
|
||||
<button
|
||||
ref="restoreButtonRef"
|
||||
v-if="isMinimized"
|
||||
@mousedown="onRestoreButtonMouseDown"
|
||||
@click="handleClickRestoreButton"
|
||||
:style="{ left: `${restoreButtonPosition.x}px`, top: `${restoreButtonPosition.y}px`, width: '50px', height: '50px' }"
|
||||
class="fixed z-[100] flex items-center justify-center bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 pointer-events-auto cursor-grab active:cursor-grabbing"
|
||||
:title="t('common.restore')"
|
||||
>
|
||||
<i class="fas fa-window-restore fa-lg"></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="!isMinimized"
|
||||
:style="computedModalStyle"
|
||||
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border pointer-events-auto relative"
|
||||
>
|
||||
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0">
|
||||
<h3 class="text-base font-semibold truncate">
|
||||
<i class="fas fa-desktop mr-2 text-text-secondary"></i>
|
||||
{{ t('remoteDesktopModal.title') }} - {{ props.connection?.name || props.connection?.host || t('remoteDesktopModal.titlePlaceholder') }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-xs px-2 py-0.5 rounded"
|
||||
:class="{
|
||||
'bg-yellow-200 text-yellow-800': connectionStatus === 'connecting',
|
||||
@@ -471,6 +632,13 @@ const computedModalStyle = computed(() => {
|
||||
}">
|
||||
{{ t('remoteDesktopModal.status.' + connectionStatus) }}
|
||||
</span>
|
||||
<button
|
||||
@click="minimizeModal"
|
||||
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
|
||||
:title="t('common.minimize')"
|
||||
>
|
||||
<i class="fas fa-window-minimize fa-sm"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
|
||||
@@ -491,7 +659,7 @@ const computedModalStyle = computed(() => {
|
||||
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
|
||||
<p class="text-sm">{{ statusMessage }}</p>
|
||||
<button v-if="connectionStatus === 'error'"
|
||||
@click="() => connectRdp()"
|
||||
@click="() => handleConnection()"
|
||||
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
@@ -523,17 +691,23 @@ const computedModalStyle = computed(() => {
|
||||
/>
|
||||
<!-- 添加重新连接按钮 -->
|
||||
<button
|
||||
@click="connectRdp"
|
||||
:disabled="connectionStatus === 'connecting'"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
:title="t('remoteDesktopModal.reconnectTooltip')"
|
||||
@click="handleConnection"
|
||||
:disabled="connectionStatus === 'connecting'"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
:title="t('remoteDesktopModal.reconnectTooltip')"
|
||||
>
|
||||
{{ t('common.reconnect') }}
|
||||
{{ t('common.reconnect') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 bg-transparent hover:bg-primary-dark hover:bg-opacity-30"
|
||||
title="Resize"
|
||||
@mousedown.stop="initResize"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.rdp-display-container {
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
// @ts-ignore - guacamole-common-js 缺少官方类型定义
|
||||
import Guacamole from 'guacamole-common-js';
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionInfo | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
let saveWidthTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveHeightTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const DEBOUNCE_DELAY = 500; // ms
|
||||
|
||||
const vncDisplayRef = ref<HTMLDivElement | null>(null);
|
||||
const vncContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const guacClient = ref<any | null>(null);
|
||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||
const isResizing = ref(false);
|
||||
const resizeStartX = ref(0);
|
||||
const resizeStartY = ref(0);
|
||||
const initialModalWidthForResize = ref(0);
|
||||
const initialModalHeightForResize = ref(0);
|
||||
const statusMessage = ref('');
|
||||
const keyboard = ref<any | null>(null);
|
||||
const mouse = ref<any | null>(null);
|
||||
// Initialize desiredModalWidth and desiredModalHeight from store or defaults
|
||||
const initialStoreWidth = settingsStore.settings.vncModalWidth
|
||||
? parseInt(settingsStore.settings.vncModalWidth, 10)
|
||||
: 1024;
|
||||
const initialStoreHeight = settingsStore.settings.vncModalHeight
|
||||
? parseInt(settingsStore.settings.vncModalHeight, 10)
|
||||
: 768;
|
||||
|
||||
const MIN_MODAL_WIDTH = 800;
|
||||
const MIN_MODAL_HEIGHT = 600;
|
||||
|
||||
const desiredModalWidth = ref(Math.max(MIN_MODAL_WIDTH, isNaN(initialStoreWidth) ? MIN_MODAL_WIDTH : initialStoreWidth));
|
||||
const desiredModalHeight = ref(Math.max(MIN_MODAL_HEIGHT, isNaN(initialStoreHeight) ? MIN_MODAL_HEIGHT : initialStoreHeight));
|
||||
const isKeyboardDisabledForInput = ref(false);
|
||||
const isMinimized = ref(false);
|
||||
const restoreButtonRef = ref<HTMLButtonElement | null>(null);
|
||||
const isDraggingRestoreButton = ref(false);
|
||||
const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered (25 is half of button height 50px)
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
let hasDragged = false;
|
||||
|
||||
let remoteDesktopWsBaseUrl: string; // Renamed for clarity
|
||||
const LOCAL_BACKEND_URL_FOR_PROXY = 'ws://localhost:3001'; // Main backend's WebSocket for proxying
|
||||
|
||||
if (window.location.hostname === 'localhost') {
|
||||
// For local development, VNC will also go through the main backend's proxy
|
||||
remoteDesktopWsBaseUrl = `${LOCAL_BACKEND_URL_FOR_PROXY}/ws/rdp-proxy`; // Use the same RDP proxy path
|
||||
} else {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHostAndPort = window.location.host;
|
||||
// For deployed environments, assume the proxy is at /ws/rdp-proxy relative to the main backend
|
||||
remoteDesktopWsBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws/rdp-proxy`;
|
||||
}
|
||||
|
||||
const handleConnection = async () => {
|
||||
if (!props.connection || !vncDisplayRef.value) {
|
||||
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
|
||||
connectionStatus.value = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
while (vncDisplayRef.value.firstChild) {
|
||||
vncDisplayRef.value.removeChild(vncDisplayRef.value.firstChild);
|
||||
}
|
||||
disconnectGuacamole();
|
||||
|
||||
connectionStatus.value = 'connecting';
|
||||
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
|
||||
|
||||
try {
|
||||
const connectionsStore = useConnectionsStore();
|
||||
// Pass width and height to the token generation, backend will forward to gateway
|
||||
const token = await connectionsStore.getVncSessionToken(props.connection.id, desiredModalWidth.value, desiredModalHeight.value);
|
||||
if (!token) {
|
||||
throw new Error('VNC Token not found from store action');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||
// The backend proxy (/ws/rdp-proxy) expects token, width, height, dpi.
|
||||
// For VNC, DPI is less critical but the proxy might expect it. Send a default or let backend handle.
|
||||
// The backend's websocket.ts rdp-proxy handler now calculates DPI if not provided or uses a default.
|
||||
// We need to ensure width and height are passed for the proxy to correctly forward.
|
||||
const tunnelUrl = `${remoteDesktopWsBaseUrl}?token=${encodeURIComponent(token)}&width=${desiredModalWidth.value}&height=${desiredModalHeight.value}`;
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||
|
||||
tunnel.onerror = (status: any) => {
|
||||
const errorMessage = status.message || 'Unknown tunnel error';
|
||||
const errorCode = status.code || 'N/A';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
guacClient.value = new Guacamole.Client(tunnel);
|
||||
guacClient.value.keepAliveFrequency = 3000;
|
||||
|
||||
vncDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
|
||||
|
||||
guacClient.value.onstatechange = (state: number) => {
|
||||
let currentStatus = '';
|
||||
let i18nKeyPart = 'unknownState';
|
||||
|
||||
switch (state) {
|
||||
case 0: i18nKeyPart = 'idle'; currentStatus = 'disconnected'; break;
|
||||
case 1: i18nKeyPart = 'connectingVnc'; currentStatus = 'connecting'; break;
|
||||
case 2: i18nKeyPart = 'waiting'; currentStatus = 'connecting'; break;
|
||||
case 3:
|
||||
i18nKeyPart = 'connected';
|
||||
currentStatus = 'connected';
|
||||
setupInputListeners();
|
||||
nextTick(() => {
|
||||
const displayEl = guacClient.value?.getDisplay()?.getElement();
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
}
|
||||
// Sync size on connect
|
||||
if (vncDisplayRef.value && guacClient.value) {
|
||||
const displayWidth = vncDisplayRef.value.offsetWidth;
|
||||
const displayHeight = vncDisplayRef.value.offsetHeight;
|
||||
if (displayWidth > 0 && displayHeight > 0) {
|
||||
console.log(`[VncModal] Initial resize on connect: ${displayWidth}x${displayHeight}`);
|
||||
guacClient.value.sendSize(displayWidth, displayHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
nextTick(() => {
|
||||
if (vncDisplayRef.value && guacClient.value) {
|
||||
const canvases = vncDisplayRef.value.querySelectorAll('canvas');
|
||||
canvases.forEach((canvas) => { canvas.style.zIndex = '999'; });
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
break;
|
||||
case 4: i18nKeyPart = 'disconnecting'; currentStatus = 'disconnected'; break;
|
||||
case 5: i18nKeyPart = 'disconnected'; currentStatus = 'disconnected'; break;
|
||||
}
|
||||
statusMessage.value = t(`remoteDesktopModal.status.${i18nKeyPart}`, { state });
|
||||
if (currentStatus) connectionStatus.value = currentStatus as 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
};
|
||||
|
||||
guacClient.value.onerror = (status: any) => {
|
||||
const errorMessage = status.message || 'Unknown client error';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
guacClient.value.connect('');
|
||||
|
||||
} catch (error: any) {
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectGuacamole();
|
||||
}
|
||||
};
|
||||
|
||||
const trySyncClipboardOnDisplayFocus = async () => {
|
||||
if (!guacClient.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const currentClipboardText = await navigator.clipboard.readText();
|
||||
if (currentClipboardText && guacClient.value) {
|
||||
// @ts-ignore
|
||||
const stream = guacClient.value.createClipboardStream('text/plain');
|
||||
// @ts-ignore
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(currentClipboardText);
|
||||
writer.sendEnd();
|
||||
console.log('[VncModal] Sent clipboard to VNC on display focus:', currentClipboardText.substring(0, 50) + (currentClipboardText.length > 50 ? '...' : ''));
|
||||
}
|
||||
} catch (err) {
|
||||
// This error is expected if the document/tab is not focused when the VNC display element gets focus.
|
||||
// Or if clipboard permissions are not granted.
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
// console.log('[VncModal] Clipboard read on display focus skipped: Document not focused or permission denied.');
|
||||
} else {
|
||||
console.warn('[VncModal] Could not read clipboard on display focus, or other error:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupInputListeners = () => {
|
||||
if (!guacClient.value || !vncDisplayRef.value) return;
|
||||
try {
|
||||
const displayEl = guacClient.value.getDisplay().getElement() as HTMLElement;
|
||||
displayEl.tabIndex = 0;
|
||||
|
||||
const handleVncDisplayClick = () => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && (activeElement.id === 'modal-width' || activeElement.id === 'modal-height')) {
|
||||
activeElement.blur();
|
||||
}
|
||||
// Ensure the VNC display element gets focus when clicked
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
}
|
||||
};
|
||||
displayEl.addEventListener('click', handleVncDisplayClick);
|
||||
|
||||
const handleMouseEnter = () => { if (displayEl) displayEl.style.cursor = 'none'; };
|
||||
const handleMouseLeave = () => { if (displayEl) displayEl.style.cursor = 'default'; };
|
||||
displayEl.addEventListener('mouseenter', handleMouseEnter);
|
||||
displayEl.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// @ts-ignore
|
||||
mouse.value = new Guacamole.Mouse(displayEl);
|
||||
const display = guacClient.value.getDisplay();
|
||||
display.showCursor(true);
|
||||
|
||||
const cursorLayer = display.getCursorLayer();
|
||||
if (cursorLayer) {
|
||||
const cursorElement = cursorLayer.getElement();
|
||||
if (cursorElement) {
|
||||
cursorElement.style.zIndex = '1000';
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
mouse.value.onmousedown = mouse.value.onmouseup = mouse.value.onmousemove = (mouseState: any) => {
|
||||
if (guacClient.value) {
|
||||
guacClient.value.sendMouseState(mouseState);
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
keyboard.value = new Guacamole.Keyboard(displayEl);
|
||||
|
||||
keyboard.value.onkeydown = (keysym: number) => {
|
||||
if (guacClient.value && !isKeyboardDisabledForInput.value) {
|
||||
guacClient.value.sendKeyEvent(1, keysym);
|
||||
}
|
||||
};
|
||||
keyboard.value.onkeyup = (keysym: number) => {
|
||||
if (guacClient.value && !isKeyboardDisabledForInput.value) {
|
||||
guacClient.value.sendKeyEvent(0, keysym);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for host copy events to send to VNC
|
||||
// document.addEventListener('copy', handleHostCopy); // Removed this
|
||||
// displayEl.addEventListener('mouseenter', trySyncClipboardOnMouseEnter); // Changed to focus event
|
||||
displayEl.addEventListener('focus', trySyncClipboardOnDisplayFocus);
|
||||
|
||||
} catch (inputError) {
|
||||
console.error("Error setting up VNC input listeners:", inputError);
|
||||
statusMessage.value = t('remoteDesktopModal.errors.inputError');
|
||||
}
|
||||
};
|
||||
|
||||
const removeInputListeners = () => {
|
||||
// Remove host copy event listener
|
||||
// document.removeEventListener('copy', handleHostCopy); // Removed this
|
||||
if (guacClient.value) {
|
||||
const displayEl = guacClient.value.getDisplay()?.getElement();
|
||||
if (displayEl) {
|
||||
// displayEl.removeEventListener('mouseenter', trySyncClipboardOnMouseEnter); // Changed to focus event
|
||||
displayEl.removeEventListener('focus', trySyncClipboardOnDisplayFocus);
|
||||
try {
|
||||
if (displayEl) {
|
||||
displayEl.style.cursor = 'default';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not reset cursor on VNC display element:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rest of the cleanup for keyboard and mouse can remain outside the guacClient.value check
|
||||
// as they are independent refs.
|
||||
if (keyboard.value) {
|
||||
keyboard.value.onkeydown = null;
|
||||
keyboard.value.onkeyup = null;
|
||||
keyboard.value = null;
|
||||
}
|
||||
if (mouse.value) {
|
||||
mouse.value.onmousedown = null;
|
||||
mouse.value.onmouseup = null;
|
||||
mouse.value.onmousemove = null;
|
||||
mouse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const disableVncKeyboard = () => {
|
||||
isKeyboardDisabledForInput.value = true;
|
||||
};
|
||||
|
||||
const enableVncKeyboard = () => {
|
||||
isKeyboardDisabledForInput.value = false;
|
||||
nextTick(() => {
|
||||
const displayEl = guacClient.value?.getDisplay()?.getElement();
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const minimizeModal = () => {
|
||||
isMinimized.value = true;
|
||||
};
|
||||
|
||||
const restoreModal = () => {
|
||||
isMinimized.value = false;
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseDown = (event: MouseEvent) => {
|
||||
if (!restoreButtonRef.value) return;
|
||||
hasDragged = false; // Reset drag flag
|
||||
isDraggingRestoreButton.value = true;
|
||||
dragOffsetX = event.clientX - restoreButtonRef.value.getBoundingClientRect().left;
|
||||
dragOffsetY = event.clientY - restoreButtonRef.value.getBoundingClientRect().top;
|
||||
// Prevent text selection while dragging
|
||||
event.preventDefault();
|
||||
document.addEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.addEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseMove = (event: MouseEvent) => {
|
||||
if (!isDraggingRestoreButton.value) return;
|
||||
hasDragged = true; // Set drag flag if mouse moves
|
||||
let newX = event.clientX - dragOffsetX;
|
||||
let newY = event.clientY - dragOffsetY;
|
||||
|
||||
// Constrain movement within viewport
|
||||
const buttonWidth = 50; // As defined in style
|
||||
const buttonHeight = 50; // As defined in style
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - buttonWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - buttonHeight));
|
||||
|
||||
restoreButtonPosition.value = { x: newX, y: newY };
|
||||
};
|
||||
|
||||
const onRestoreButtonMouseUp = () => {
|
||||
isDraggingRestoreButton.value = false;
|
||||
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
// Click event will fire after mouseup. If we dragged, we don't want click to restore.
|
||||
// The handleClickRestoreButton will check hasDragged.
|
||||
};
|
||||
|
||||
const handleClickRestoreButton = () => {
|
||||
if (!hasDragged) {
|
||||
restoreModal();
|
||||
}
|
||||
// Reset for next interaction
|
||||
hasDragged = false;
|
||||
};
|
||||
|
||||
const disconnectGuacamole = () => {
|
||||
removeInputListeners();
|
||||
isKeyboardDisabledForInput.value = false;
|
||||
if (guacClient.value) {
|
||||
guacClient.value.disconnect();
|
||||
guacClient.value = null;
|
||||
}
|
||||
if (vncDisplayRef.value) {
|
||||
while (vncDisplayRef.value.firstChild) {
|
||||
vncDisplayRef.value.removeChild(vncDisplayRef.value.firstChild);
|
||||
}
|
||||
}
|
||||
if (connectionStatus.value !== 'error') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
statusMessage.value = t('remoteDesktopModal.status.disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
disconnectGuacamole();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
watch(desiredModalWidth, (newWidth, oldWidth) => {
|
||||
if (newWidth === oldWidth && typeof newWidth === 'number' && typeof oldWidth === 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const validatedWidth = Math.max(MIN_MODAL_WIDTH, Number(newWidth) || MIN_MODAL_WIDTH);
|
||||
|
||||
if (validatedWidth !== Number(newWidth)) {
|
||||
nextTick(() => {
|
||||
desiredModalWidth.value = validatedWidth;
|
||||
});
|
||||
}
|
||||
|
||||
if (saveWidthTimeout) clearTimeout(saveWidthTimeout);
|
||||
saveWidthTimeout = setTimeout(() => {
|
||||
if (String(validatedWidth) !== settingsStore.settings.vncModalWidth) {
|
||||
settingsStore.updateSetting('vncModalWidth', String(validatedWidth));
|
||||
} else {
|
||||
// console.log(`[VncModal] 防抖保存 - 宽度 ${validatedWidth} 与存储值匹配。跳过冗余保存。`);
|
||||
}
|
||||
}, DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
watch(desiredModalHeight, (newHeight, oldHeight) => {
|
||||
if (newHeight === oldHeight && typeof newHeight === 'number' && typeof oldHeight === 'number') {
|
||||
// console.log(`[VncModal] 高度监听触发,但值 (${newHeight}) 未改变。跳过。`);
|
||||
return;
|
||||
}
|
||||
// console.log(`[VncModal] 监听 desiredModalHeight 触发: ${oldHeight} -> ${newHeight}`);
|
||||
|
||||
const validatedHeight = Math.max(MIN_MODAL_HEIGHT, Number(newHeight) || MIN_MODAL_HEIGHT);
|
||||
|
||||
if (validatedHeight !== Number(newHeight)) {
|
||||
nextTick(() => {
|
||||
desiredModalHeight.value = validatedHeight;
|
||||
});
|
||||
}
|
||||
|
||||
if (saveHeightTimeout) clearTimeout(saveHeightTimeout);
|
||||
saveHeightTimeout = setTimeout(() => {
|
||||
// console.log(`[VncModal] 防抖保存 - 保存高度: ${validatedHeight}`);
|
||||
if (String(validatedHeight) !== settingsStore.settings.vncModalHeight) {
|
||||
settingsStore.updateSetting('vncModalHeight', String(validatedHeight));
|
||||
} else {
|
||||
// console.log(`[VncModal] 防抖保存 - 高度 ${validatedHeight} 与存储值匹配。跳过冗余保存。`);
|
||||
}
|
||||
}, DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
if (props.connection) {
|
||||
nextTick(async () => {
|
||||
await handleConnection();
|
||||
});
|
||||
} else {
|
||||
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||
connectionStatus.value = 'error';
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectGuacamole();
|
||||
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
|
||||
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
|
||||
// Clean up resize listeners if component is unmounted while resizing
|
||||
if (isResizing.value) {
|
||||
document.removeEventListener('mousemove', doResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.connection, (newConnection, oldConnection) => {
|
||||
if (newConnection && newConnection.id !== oldConnection?.id) {
|
||||
nextTick(async () => {
|
||||
await handleConnection();
|
||||
});
|
||||
} else if (!newConnection) {
|
||||
disconnectGuacamole();
|
||||
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||
connectionStatus.value = 'error';
|
||||
}
|
||||
});
|
||||
|
||||
const computedModalStyle = computed(() => {
|
||||
const actualWidth = Math.max(MIN_MODAL_WIDTH, desiredModalWidth.value);
|
||||
const actualHeight = Math.max(MIN_MODAL_HEIGHT, desiredModalHeight.value);
|
||||
return {
|
||||
width: `${actualWidth}px`,
|
||||
height: `${actualHeight}px`,
|
||||
};
|
||||
});
|
||||
watchEffect(() => {
|
||||
// 依赖 computedModalStyle,当其变化时此 effect 会重新运行
|
||||
const currentStyle = computedModalStyle.value;
|
||||
|
||||
if (guacClient.value && connectionStatus.value === 'connected' && vncDisplayRef.value) {
|
||||
// 使用 nextTick 确保 DOM 更新完毕,vncDisplayRef 的尺寸已根据 currentStyle 刷新
|
||||
nextTick(() => {
|
||||
if (vncDisplayRef.value && guacClient.value) { // 再次检查,因为 nextTick 是异步的
|
||||
const displayWidth = vncDisplayRef.value.offsetWidth;
|
||||
const displayHeight = vncDisplayRef.value.offsetHeight;
|
||||
|
||||
if (displayWidth > 0 && displayHeight > 0) {
|
||||
console.log(`[VncModal] Resizing VNC display to: ${displayWidth}x${displayHeight} due to style change.`);
|
||||
guacClient.value.sendSize(displayWidth, displayHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const initResize = (event: MouseEvent) => {
|
||||
isResizing.value = true;
|
||||
resizeStartX.value = event.clientX;
|
||||
resizeStartY.value = event.clientY;
|
||||
initialModalWidthForResize.value = desiredModalWidth.value;
|
||||
initialModalHeightForResize.value = desiredModalHeight.value;
|
||||
|
||||
document.addEventListener('mousemove', doResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
event.preventDefault(); // Prevent text selection or other default browser actions
|
||||
};
|
||||
|
||||
const doResize = (event: MouseEvent) => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
const deltaX = event.clientX - resizeStartX.value;
|
||||
const deltaY = event.clientY - resizeStartY.value;
|
||||
|
||||
let newWidth = initialModalWidthForResize.value + deltaX;
|
||||
let newHeight = initialModalHeightForResize.value + deltaY;
|
||||
|
||||
// Apply minimum size constraints
|
||||
newWidth = Math.max(MIN_MODAL_WIDTH, newWidth);
|
||||
newHeight = Math.max(MIN_MODAL_HEIGHT, newHeight);
|
||||
|
||||
desiredModalWidth.value = newWidth;
|
||||
desiredModalHeight.value = newHeight;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (!isResizing.value) return;
|
||||
isResizing.value = false;
|
||||
document.removeEventListener('mousemove', doResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
// The existing watchEffect for computedModalStyle will handle Guacamole resize
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
||||
isMinimized ? '' : 'bg-overlay',
|
||||
isMinimized ? 'pointer-events-none' : '' // 允许恢复按钮接收事件
|
||||
]"
|
||||
>
|
||||
<button
|
||||
ref="restoreButtonRef"
|
||||
v-if="isMinimized"
|
||||
@mousedown="onRestoreButtonMouseDown"
|
||||
@click="handleClickRestoreButton"
|
||||
:style="{ left: `${restoreButtonPosition.x}px`, top: `${restoreButtonPosition.y}px`, width: '50px', height: '50px' }"
|
||||
class="fixed z-[100] flex items-center justify-center bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 pointer-events-auto cursor-grab active:cursor-grabbing"
|
||||
:title="t('common.restore')"
|
||||
>
|
||||
<i class="fas fa-window-restore fa-lg"></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="!isMinimized"
|
||||
:style="computedModalStyle"
|
||||
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border pointer-events-auto relative"
|
||||
>
|
||||
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0">
|
||||
<h3 class="text-base font-semibold truncate">
|
||||
<i class="fas fa-plug mr-2 text-text-secondary"></i>
|
||||
{{ t('vncModal.title') }} - {{ props.connection?.name || props.connection?.host || t('remoteDesktopModal.titlePlaceholder') }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-xs px-2 py-0.5 rounded"
|
||||
:class="{
|
||||
'bg-yellow-200 text-yellow-800': connectionStatus === 'connecting',
|
||||
'bg-green-200 text-green-800': connectionStatus === 'connected',
|
||||
'bg-red-200 text-red-800': connectionStatus === 'error',
|
||||
'bg-gray-200 text-gray-800': connectionStatus === 'disconnected'
|
||||
}">
|
||||
{{ t('remoteDesktopModal.status.' + connectionStatus) }}
|
||||
</span>
|
||||
<button
|
||||
@click="minimizeModal"
|
||||
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
|
||||
:title="t('common.minimize')"
|
||||
>
|
||||
<i class="fas fa-window-minimize fa-sm"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
|
||||
:title="t('common.close')"
|
||||
>
|
||||
<i class="fas fa-times fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="vncContainerRef" class="relative bg-black overflow-hidden flex-1">
|
||||
<div ref="vncDisplayRef" class="vnc-display-container w-full h-full">
|
||||
</div>
|
||||
<div v-if="connectionStatus === 'connecting' || connectionStatus === 'error'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-75 text-white p-4 z-10">
|
||||
<div class="text-center">
|
||||
<i v-if="connectionStatus === 'connecting'" class="fas fa-spinner fa-spin fa-2x mb-3"></i>
|
||||
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
|
||||
<p class="text-sm">{{ statusMessage }}</p>
|
||||
<button v-if="connectionStatus === 'error'"
|
||||
@click="() => handleConnection()"
|
||||
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 border-t border-border flex-shrink-0 text-xs text-text-secondary bg-header flex items-center justify-end">
|
||||
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<label for="modal-width" class="text-xs ml-2">{{ t('common.width') }}:</label>
|
||||
<input
|
||||
id="modal-width"
|
||||
type="number"
|
||||
v-model.number="desiredModalWidth"
|
||||
step="10"
|
||||
class="w-16 px-1 py-0.5 text-xs border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@focus="disableVncKeyboard"
|
||||
@blur="enableVncKeyboard"
|
||||
/>
|
||||
<label for="modal-height" class="text-xs">{{ t('common.height') }}:</label>
|
||||
<input
|
||||
id="modal-height"
|
||||
type="number"
|
||||
v-model.number="desiredModalHeight"
|
||||
step="10"
|
||||
class="w-16 px-1 py-0.5 text-xs border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@focus="disableVncKeyboard"
|
||||
@blur="enableVncKeyboard"
|
||||
/>
|
||||
<button
|
||||
@click="handleConnection"
|
||||
:disabled="connectionStatus === 'connecting'"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
:title="t('remoteDesktopModal.reconnectTooltip')"
|
||||
>
|
||||
{{ t('common.reconnect') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 bg-transparent hover:bg-primary-dark hover:bg-opacity-30"
|
||||
title="Resize"
|
||||
@mousedown.stop="initResize"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vnc-display-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vnc-display-container :deep(div) {
|
||||
}
|
||||
|
||||
.vnc-display-container :deep(canvas) {
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
@@ -306,15 +306,8 @@ const handleConnect = (connectionId: number, event?: MouseEvent | KeyboardEvent)
|
||||
|
||||
closeContextMenu(); // 关闭右键菜单
|
||||
|
||||
if (connection.type === 'RDP') {
|
||||
console.log(`[WkspConnList] RDP connection clicked (ID: ${connectionId}). Calling sessionStore.openRdpModal.`);
|
||||
// --- 修改:调用 Store Action ---
|
||||
sessionStore.openRdpModal(connection);
|
||||
} else {
|
||||
console.log(`[WkspConnList] Non-RDP connection clicked (ID: ${connectionId}, Type: ${connection.type}). Emitting connect-request.`);
|
||||
// 对于非 RDP 连接,保持原有逻辑,发出事件给父组件处理
|
||||
emit('connect-request', connectionId);
|
||||
}
|
||||
// 统一发出 connect-request 事件,让 sessionStore.handleConnectRequest 处理模态框和会话
|
||||
emit('connect-request', connectionId);
|
||||
};
|
||||
|
||||
// --- 移除 closeRdpModal 方法 ---
|
||||
@@ -690,7 +683,7 @@ const cancelEditingTag = () => {
|
||||
@click.right.prevent
|
||||
@contextmenu.prevent="showContextMenu($event, conn)"
|
||||
>
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-plug' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
|
||||
{{ conn.name || conn.host }}
|
||||
</span>
|
||||
@@ -710,7 +703,7 @@ const cancelEditingTag = () => {
|
||||
@click.right.prevent
|
||||
@contextmenu.prevent="showContextMenu($event, conn)"
|
||||
>
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-chalkboard' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
|
||||
{{ conn.name || conn.host }}
|
||||
</span>
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"password": "Password:",
|
||||
"privateKey": "Private Key:",
|
||||
"passphrase": "Passphrase:",
|
||||
"vncPassword": "VNC Password",
|
||||
"optional": "Optional",
|
||||
"confirm": "Confirm Add",
|
||||
"adding": "Adding...",
|
||||
@@ -150,6 +151,7 @@
|
||||
"errorPrivateKeyRequired": "Private key is required for key authentication.",
|
||||
"errorPasswordRequiredOnSwitch": "Password is required when switching to password authentication.",
|
||||
"errorPrivateKeyRequiredOnSwitch": "Private key is required when switching to key authentication.",
|
||||
"errorVncPasswordRequired": "VNC password is required.",
|
||||
"errorPort": "Port must be between 1 and 65535.",
|
||||
"errorAdd": "Failed to add connection: {error}",
|
||||
"titleEdit": "Edit Connection",
|
||||
@@ -165,6 +167,7 @@
|
||||
"connectionType": "Connection Type:",
|
||||
"typeSsh": "SSH",
|
||||
"typeRdp": "RDP",
|
||||
"typeVnc": "VNC",
|
||||
"sectionBasic": "Basic Information",
|
||||
"sectionAuth": "Authentication",
|
||||
"sectionAdvanced": "Advanced Options",
|
||||
@@ -812,7 +815,9 @@
|
||||
"reconnect": "Reconnect",
|
||||
"retry": "Retry",
|
||||
"sortAscending": "Ascending",
|
||||
"sortDescending": "Descending"
|
||||
"sortDescending": "Descending",
|
||||
"restore": "Restore",
|
||||
"minimize": "Minimize"
|
||||
},
|
||||
"layoutConfigurator": {
|
||||
"title": "Layout Configurator",
|
||||
@@ -897,6 +902,7 @@
|
||||
"connectingWs": "Connecting WebSocket...",
|
||||
"idle": "Idle",
|
||||
"connectingRdp": "Connecting Remote Desktop...",
|
||||
"connectingVnc": "Connecting VNC...",
|
||||
"waiting": "Waiting for server response...",
|
||||
"connected": "Connected",
|
||||
"disconnecting": "Disconnecting...",
|
||||
@@ -916,6 +922,9 @@
|
||||
},
|
||||
"reconnectTooltip": "Reconnect to the remote desktop"
|
||||
},
|
||||
"vncModal": {
|
||||
"title": "VNC Session"
|
||||
},
|
||||
"commandInputBar": {
|
||||
"placeholder": "Enter command and press Enter to send...",
|
||||
"searchPlaceholder": "Search in terminal...",
|
||||
|
||||
@@ -86,7 +86,9 @@
|
||||
"sortAscending": "昇順",
|
||||
"sortDescending": "降順",
|
||||
"testing": "テスト中...",
|
||||
"width": "幅"
|
||||
"width": "幅",
|
||||
"restore": "元に戻す",
|
||||
"minimize": "最小化"
|
||||
},
|
||||
"connections": {
|
||||
"actions": {
|
||||
@@ -589,6 +591,9 @@
|
||||
"title": "リモートデスクトップ",
|
||||
"titlePlaceholder": "リモートデスクトップ接続"
|
||||
},
|
||||
"vncModal": {
|
||||
"title": "VNCセッション"
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"customizeButton": "外観をカスタマイズ",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"password": "密码:",
|
||||
"privateKey": "私钥:",
|
||||
"passphrase": "私钥密码:",
|
||||
"vncPassword": "VNC 密码:",
|
||||
"optional": "可选",
|
||||
"confirm": "确认添加",
|
||||
"adding": "正在添加...",
|
||||
@@ -150,6 +151,7 @@
|
||||
"errorPrivateKeyRequired": "使用密钥认证时,私钥为必填项。",
|
||||
"errorPasswordRequiredOnSwitch": "切换到密码认证时,密码为必填项。",
|
||||
"errorPrivateKeyRequiredOnSwitch": "切换到密钥认证时,私钥为必填项。",
|
||||
"errorVncPasswordRequired": "VNC 密码是必填项。",
|
||||
"errorPort": "端口号必须在 1 到 65535 之间。",
|
||||
"errorAdd": "添加连接失败: {error}",
|
||||
"titleEdit": "编辑连接",
|
||||
@@ -165,6 +167,7 @@
|
||||
"connectionType": "连接类型",
|
||||
"typeSsh": "SSH",
|
||||
"typeRdp": "RDP",
|
||||
"typeVnc": "VNC",
|
||||
"sectionBasic": "基本信息",
|
||||
"sectionAuth": "认证信息",
|
||||
"sectionAdvanced": "高级选项",
|
||||
@@ -813,7 +816,9 @@
|
||||
"reconnect": "重新连接",
|
||||
"retry": "重试",
|
||||
"sortAscending": "升序",
|
||||
"sortDescending": "降序"
|
||||
"sortDescending": "降序",
|
||||
"restore": "还原",
|
||||
"minimize": "最小化"
|
||||
},
|
||||
"layoutConfigurator": {
|
||||
"title": "布局管理器",
|
||||
@@ -900,8 +905,9 @@
|
||||
"connectingWs": "正在连接 WebSocket...",
|
||||
"idle": "空闲",
|
||||
"connectingRdp": "正在连接远程桌面...",
|
||||
"connectingVnc": "正在连接 VNC...",
|
||||
"waiting": "等待服务器响应...",
|
||||
"connecting": "连接中...",
|
||||
"connecting": "连接中...",
|
||||
"error": "错误",
|
||||
"connected": "已连接",
|
||||
"disconnecting": "正在断开连接...",
|
||||
@@ -919,6 +925,9 @@
|
||||
},
|
||||
"reconnectTooltip": "重新连接到远程桌面"
|
||||
},
|
||||
"vncModal": {
|
||||
"title": "VNC 会话"
|
||||
},
|
||||
"commandInputBar": {
|
||||
"placeholder": "在此输入命令后按 Enter 发送到终端...",
|
||||
"searchPlaceholder": "在终端中搜索...",
|
||||
|
||||
@@ -5,7 +5,7 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
export interface ConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP'; // Use uppercase to match backend data
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase to match backend data
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -17,6 +17,7 @@ export interface ConnectionInfo {
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
notes?: string | null; // 新增备注字段
|
||||
vncPassword?: string; // VNC specific password
|
||||
}
|
||||
|
||||
// 定义 Store State 的接口
|
||||
@@ -87,14 +88,15 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
// 更新参数类型以接受新的认证字段
|
||||
async addConnection(newConnectionData: {
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP'; // Use uppercase
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
auth_method: 'password' | 'key'; // SSH specific
|
||||
password?: string; // SSH password or general password
|
||||
private_key?: string; // SSH specific
|
||||
passphrase?: string; // SSH specific
|
||||
vncPassword?: string; // VNC specific password
|
||||
proxy_id?: number | null;
|
||||
tag_ids?: number[]; // 新增:允许传入 tag_ids
|
||||
}) {
|
||||
@@ -122,8 +124,8 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
|
||||
// 更新连接 Action
|
||||
// 更新参数类型以包含 proxy_id 和 tag_ids
|
||||
// Update parameter type to include 'type'
|
||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP'; password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
||||
// Update parameter type to include 'type' and VNC fields
|
||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
@@ -282,5 +284,38 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// +++ 新增:获取 VNC 会话令牌 +++
|
||||
async getVncSessionToken(connectionId: number, width?: number, height?: number): Promise<string | null> {
|
||||
// this.isLoading = true; // 考虑是否需要独立的加载状态,或者由调用方处理
|
||||
// this.error = null;
|
||||
try {
|
||||
let apiUrl = `/connections/${connectionId}/vnc-session`;
|
||||
const params = new URLSearchParams();
|
||||
if (width !== undefined) {
|
||||
params.append('width', String(width));
|
||||
}
|
||||
if (height !== undefined) {
|
||||
params.append('height', String(height));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
apiUrl += `?${queryString}`;
|
||||
}
|
||||
// 调用后端 API POST /connections/:id/vnc-session (现在带有可选的 width/height 查询参数)
|
||||
const response = await apiClient.post<{ token: string }>(apiUrl);
|
||||
return response.data.token;
|
||||
} catch (err: any) {
|
||||
console.error(`获取 VNC 会话令牌失败 (连接 ID: ${connectionId}):`, err);
|
||||
// this.error = err.response?.data?.message || err.message || '获取 VNC 会话令牌时发生未知错误。';
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('未授权,需要登录才能获取 VNC 会话令牌。');
|
||||
}
|
||||
// 对于这种一次性获取数据的操作,错误通常由调用方处理并显示给用户
|
||||
throw err; // 重新抛出错误,让调用方处理
|
||||
} finally {
|
||||
// this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,10 +70,10 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
// +++ 修改:从后端加载配置(包括快捷键) +++
|
||||
async function loadConfigurationFromBackend() {
|
||||
const apiUrl = '/api/v1/settings/focus-switcher-sequence'; // 假设 API 端点不变,但返回结构改变
|
||||
console.log(`[FocusSwitcherStore] Attempting to load full configuration (sequence & shortcuts) from backend via: ${apiUrl}`);
|
||||
// console.log(`[FocusSwitcherStore] Attempting to load full configuration (sequence & shortcuts) from backend via: ${apiUrl}`);
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
console.log(`[FocusSwitcherStore] Received response from ${apiUrl}. Status: ${response.status}`);
|
||||
// console.log(`[FocusSwitcherStore] Received response from ${apiUrl}. Status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[FocusSwitcherStore] HTTP error from ${apiUrl}. Status: ${response.status}`);
|
||||
@@ -82,7 +82,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
// *** 假设后端返回 FocusSwitcherFullConfig 结构 ***
|
||||
const loadedFullConfig: FocusSwitcherFullConfig = await response.json();
|
||||
console.log(`[FocusSwitcherStore] Raw JSON received from backend:`, JSON.stringify(loadedFullConfig));
|
||||
// console.log(`[FocusSwitcherStore] Raw JSON received from backend:`, JSON.stringify(loadedFullConfig));
|
||||
|
||||
// --- 验证和设置 ---
|
||||
const availableIds = new Set(availableInputs.value.map(input => input.id));
|
||||
@@ -90,7 +90,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
// 验证 sequence
|
||||
if (Array.isArray(loadedFullConfig?.sequence) && loadedFullConfig.sequence.every(id => typeof id === 'string' && availableIds.has(id))) {
|
||||
sequenceOrder.value = loadedFullConfig.sequence;
|
||||
console.log('[FocusSwitcherStore] Successfully loaded and set sequenceOrder:', JSON.stringify(sequenceOrder.value));
|
||||
// console.log('[FocusSwitcherStore] Successfully loaded and set sequenceOrder:', JSON.stringify(sequenceOrder.value));
|
||||
} else {
|
||||
console.warn('[FocusSwitcherStore] Invalid or missing sequence in loaded config. Resetting to empty array.');
|
||||
sequenceOrder.value = [];
|
||||
@@ -113,7 +113,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
}
|
||||
}
|
||||
itemConfigs.value = validConfigs;
|
||||
console.log('[FocusSwitcherStore] Successfully loaded and set itemConfigs:', JSON.stringify(itemConfigs.value));
|
||||
// console.log('[FocusSwitcherStore] Successfully loaded and set itemConfigs:', JSON.stringify(itemConfigs.value));
|
||||
} else {
|
||||
console.warn('[FocusSwitcherStore] Invalid or missing shortcuts in loaded config. Resetting to empty object.');
|
||||
itemConfigs.value = {};
|
||||
@@ -123,20 +123,20 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
console.error(`[FocusSwitcherStore] Failed to load or parse configuration from backend (${apiUrl}):`, error);
|
||||
sequenceOrder.value = [];
|
||||
itemConfigs.value = {};
|
||||
console.log('[FocusSwitcherStore] Reset sequenceOrder and itemConfigs due to loading error.');
|
||||
// console.log('[FocusSwitcherStore] Reset sequenceOrder and itemConfigs due to loading error.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfigurationToBackend() {
|
||||
const apiUrl = '/api/v1/settings/focus-switcher-sequence'; // 假设 API 端点不变,但接受结构改变
|
||||
console.log(`[FocusSwitcherStore] Attempting to save full configuration (sequence & shortcuts) to backend via PUT: ${apiUrl}`);
|
||||
// console.log(`[FocusSwitcherStore] Attempting to save full configuration (sequence & shortcuts) to backend via PUT: ${apiUrl}`);
|
||||
try {
|
||||
// *** 构造 FocusSwitcherFullConfig 结构发送给后端 ***
|
||||
const configToSave: FocusSwitcherFullConfig = {
|
||||
sequence: sequenceOrder.value,
|
||||
shortcuts: itemConfigs.value,
|
||||
};
|
||||
console.log('[FocusSwitcherStore] Full configuration data to save:', JSON.stringify(configToSave));
|
||||
// console.log('[FocusSwitcherStore] Full configuration data to save:', JSON.stringify(configToSave));
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -145,7 +145,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
},
|
||||
body: JSON.stringify(configToSave), // *** 发送包含 sequence 和 shortcuts 的对象 ***
|
||||
});
|
||||
console.log(`[FocusSwitcherStore] Received response from PUT ${apiUrl}. Status: ${response.status}`);
|
||||
// console.log(`[FocusSwitcherStore] Received response from PUT ${apiUrl}. Status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -154,7 +154,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[FocusSwitcherStore] Configuration successfully saved to backend. Response message:', result.message);
|
||||
// console.log('[FocusSwitcherStore] Configuration successfully saved to backend. Response message:', result.message);
|
||||
} catch (error) {
|
||||
console.error(`[FocusSwitcherStore] Failed to save configuration to backend (${apiUrl}):`, error);
|
||||
// Notify user of failure
|
||||
@@ -164,17 +164,17 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
function triggerTerminalSearchActivation() {
|
||||
activateTerminalSearchTrigger.value++;
|
||||
console.log('[FocusSwitcherStore] Triggering Terminal search activation.');
|
||||
// console.log('[FocusSwitcherStore] Triggering Terminal search activation.');
|
||||
}
|
||||
|
||||
function triggerFileManagerSearchActivation() {
|
||||
activateFileManagerSearchTrigger.value++;
|
||||
console.log('[FocusSwitcherStore] Triggering FileManager search activation.');
|
||||
// console.log('[FocusSwitcherStore] Triggering FileManager search activation.');
|
||||
}
|
||||
|
||||
function toggleConfigurator(visible?: boolean) {
|
||||
isConfiguratorVisible.value = visible === undefined ? !isConfiguratorVisible.value : visible;
|
||||
console.log(`[FocusSwitcherStore] Configurator visibility set to: ${isConfiguratorVisible.value}`);
|
||||
// console.log(`[FocusSwitcherStore] Configurator visibility set to: ${isConfiguratorVisible.value}`);
|
||||
}
|
||||
|
||||
// --- 移除旧的 loadConfiguration ---
|
||||
@@ -193,13 +193,13 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
// +++ 修改:更新完整配置(包括顺序和所有快捷键) +++
|
||||
function updateConfiguration(newFullConfig: FocusSwitcherFullConfig) {
|
||||
console.log('[FocusSwitcherStore] updateConfiguration called with new full configuration:', JSON.stringify(newFullConfig));
|
||||
// console.log('[FocusSwitcherStore] updateConfiguration called with new full configuration:', JSON.stringify(newFullConfig));
|
||||
const availableIds = new Set(availableInputs.value.map(input => input.id));
|
||||
|
||||
// 更新 sequenceOrder (过滤无效 ID)
|
||||
if (Array.isArray(newFullConfig?.sequence)) {
|
||||
sequenceOrder.value = newFullConfig.sequence.filter(id => availableIds.has(id));
|
||||
console.log('[FocusSwitcherStore] sequenceOrder updated locally to:', JSON.stringify(sequenceOrder.value));
|
||||
// console.log('[FocusSwitcherStore] sequenceOrder updated locally to:', JSON.stringify(sequenceOrder.value));
|
||||
} else {
|
||||
console.warn('[FocusSwitcherStore] Invalid sequence provided in updateConfiguration. Keeping existing sequence.');
|
||||
}
|
||||
@@ -218,7 +218,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
}
|
||||
}
|
||||
itemConfigs.value = validConfigs;
|
||||
console.log('[FocusSwitcherStore] itemConfigs updated locally to:', JSON.stringify(itemConfigs.value));
|
||||
// console.log('[FocusSwitcherStore] itemConfigs updated locally to:', JSON.stringify(itemConfigs.value));
|
||||
} else {
|
||||
console.warn('[FocusSwitcherStore] Invalid shortcuts provided in updateConfiguration. Keeping existing configs.');
|
||||
}
|
||||
@@ -238,7 +238,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
const actions = registeredActions.value.get(id) || [];
|
||||
actions.push(action);
|
||||
registeredActions.value.set(id, actions);
|
||||
console.log(`[FocusSwitcherStore] Registered focus action for ID: ${id}. Total actions for this ID: ${actions.length}`);
|
||||
// console.log(`[FocusSwitcherStore] Registered focus action for ID: ${id}. Total actions for this ID: ${actions.length}`);
|
||||
|
||||
// 返回一个用于注销此特定动作的函数
|
||||
const unregister = () => {
|
||||
@@ -247,11 +247,11 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
const index = currentActions.indexOf(action);
|
||||
if (index > -1) {
|
||||
currentActions.splice(index, 1);
|
||||
console.log(`[FocusSwitcherStore] Unregistered a focus action for ID: ${id}. Remaining actions: ${currentActions.length}`);
|
||||
// console.log(`[FocusSwitcherStore] Unregistered a focus action for ID: ${id}. Remaining actions: ${currentActions.length}`);
|
||||
// 如果数组为空,可以从 Map 中移除该 ID
|
||||
if (currentActions.length === 0) {
|
||||
registeredActions.value.delete(id);
|
||||
console.log(`[FocusSwitcherStore] Removed ID ${id} from registeredActions map as it has no more actions.`);
|
||||
// console.log(`[FocusSwitcherStore] Removed ID ${id} from registeredActions map as it has no more actions.`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[FocusSwitcherStore] Attempted to unregister an action for ID ${id} that was not found.`);
|
||||
@@ -269,7 +269,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
// 修改:统一的聚焦目标 Action,现在迭代 Map 中的动作数组
|
||||
async function focusTarget(id: string): Promise<boolean> {
|
||||
console.log(`[FocusSwitcherStore] Attempting to focus target ID: ${id}`);
|
||||
// console.log(`[FocusSwitcherStore] Attempting to focus target ID: ${id}`);
|
||||
const actions = registeredActions.value.get(id);
|
||||
|
||||
if (!actions || actions.length === 0) {
|
||||
@@ -277,7 +277,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[FocusSwitcherStore] Found ${actions.length} action(s) for ID: ${id}. Iterating...`);
|
||||
// console.log(`[FocusSwitcherStore] Found ${actions.length} action(s) for ID: ${id}. Iterating...`);
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
@@ -286,14 +286,14 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
if (result === true) {
|
||||
// 如果动作返回 true,表示成功聚焦,停止迭代并返回 true
|
||||
console.log(`[FocusSwitcherStore] Successfully focused ${id} via one of its actions.`);
|
||||
// console.log(`[FocusSwitcherStore] Successfully focused ${id} via one of its actions.`);
|
||||
return true;
|
||||
} else if (result === false) {
|
||||
// 如果动作返回 false,表示尝试但失败,记录日志并继续下一个动作
|
||||
console.log(`[FocusSwitcherStore] An action for ${id} returned false (failed). Trying next action if available.`);
|
||||
// console.log(`[FocusSwitcherStore] An action for ${id} returned false (failed). Trying next action if available.`);
|
||||
} else if (result === undefined) {
|
||||
// 如果动作返回 undefined,表示跳过(例如非活动实例),记录日志并继续下一个动作
|
||||
console.log(`[FocusSwitcherStore] An action for ${id} returned undefined (skipped). Trying next action if available.`);
|
||||
// console.log(`[FocusSwitcherStore] An action for ${id} returned undefined (skipped). Trying next action if available.`);
|
||||
}
|
||||
// 如果 result 是其他值,也视为跳过或未处理
|
||||
|
||||
@@ -304,7 +304,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
}
|
||||
|
||||
// 如果遍历完所有动作都没有成功聚焦 (没有返回 true)
|
||||
console.log(`[FocusSwitcherStore] All actions for ${id} executed, but none returned true. Focus failed.`);
|
||||
// console.log(`[FocusSwitcherStore] All actions for ${id} executed, but none returned true. Focus failed.`);
|
||||
// 尝试激活搜索框(如果适用),这里的逻辑可能需要重新审视,
|
||||
// 因为激活应该由返回 false 的动作内部触发,或者由调用 focusTarget 的地方处理
|
||||
if (id === 'fileManagerSearch') {
|
||||
@@ -392,9 +392,9 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
|
||||
|
||||
// --- Initialization ---
|
||||
// Store 创建时自动从后端加载配置
|
||||
console.log('[FocusSwitcherStore] Initializing store and scheduling loadConfigurationFromBackend...'); // 使用新名称
|
||||
// console.log('[FocusSwitcherStore] Initializing store and scheduling loadConfigurationFromBackend...'); // 使用新名称
|
||||
nextTick(() => {
|
||||
console.log('[FocusSwitcherStore] nextTick triggered, calling loadConfigurationFromBackend.'); // 使用新名称
|
||||
// console.log('[FocusSwitcherStore] nextTick triggered, calling loadConfigurationFromBackend.'); // 使用新名称
|
||||
loadConfigurationFromBackend(); // 调用重命名后的加载函数
|
||||
});
|
||||
|
||||
|
||||
@@ -134,6 +134,10 @@ export const useSessionStore = defineStore('session', () => {
|
||||
const isRdpModalOpen = ref(false);
|
||||
const rdpConnectionInfo = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// --- VNC Modal State ---
|
||||
const isVncModalOpen = ref(false);
|
||||
const vncConnectionInfo = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// --- Getters ---
|
||||
const sessionTabs = computed(() => {
|
||||
return Array.from(sessions.value.values()).map(session => ({
|
||||
@@ -409,11 +413,14 @@ export const useSessionStore = defineStore('session', () => {
|
||||
* - 否则,打开一个新的会话标签页并导航到 Workspace。
|
||||
*/
|
||||
const handleConnectRequest = (connection: ConnectionInfo) => {
|
||||
console.log(`[SessionStore] handleConnectRequest called for connection: ${connection.name} (ID: ${connection.id}, Type: ${connection.type})`);
|
||||
// console.log(`[SessionStore] handleConnectRequest called for connection: ${connection.name} (ID: ${connection.id}, Type: ${connection.type})`); // 保留原始日志或移除
|
||||
|
||||
if (connection.type === 'RDP') {
|
||||
// RDP: 直接打开模态框
|
||||
// RDP: 直接打开 RDP 模态框
|
||||
openRdpModal(connection);
|
||||
} else if (connection.type === 'VNC') {
|
||||
// VNC: 直接打开 VNC 模态框
|
||||
openVncModal(connection);
|
||||
} else {
|
||||
// 非 RDP (e.g., SSH): 处理会话和导航
|
||||
const connIdStr = String(connection.id);
|
||||
@@ -785,17 +792,30 @@ export const useSessionStore = defineStore('session', () => {
|
||||
|
||||
// --- RDP Modal Actions ---
|
||||
const openRdpModal = (connection: ConnectionInfo) => {
|
||||
console.log(`[SessionStore] Opening RDP modal for connection: ${connection.name} (ID: ${connection.id})`);
|
||||
// console.log(`[SessionStore] Opening RDP modal for connection: ${connection.name} (ID: ${connection.id})`); // 保留原始日志或移除
|
||||
rdpConnectionInfo.value = connection;
|
||||
isRdpModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeRdpModal = () => {
|
||||
console.log('[SessionStore] Closing RDP modal.');
|
||||
// console.log('[SessionStore] Closing RDP modal.'); // 保留原始日志或移除
|
||||
isRdpModalOpen.value = false;
|
||||
rdpConnectionInfo.value = null; // 清除连接信息
|
||||
};
|
||||
|
||||
// --- VNC Modal Actions ---
|
||||
const openVncModal = (connection: ConnectionInfo) => {
|
||||
// console.log(`[SessionStore] Opening VNC modal for connection: ${connection.name} (ID: ${connection.id})`); // 保留原始日志或移除
|
||||
vncConnectionInfo.value = connection;
|
||||
isVncModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeVncModal = () => {
|
||||
// console.log('[SessionStore] Closing VNC modal.'); // 保留原始日志或移除
|
||||
isVncModalOpen.value = false;
|
||||
vncConnectionInfo.value = null; // 清除连接信息
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新指定会话的命令输入框内容
|
||||
*/
|
||||
@@ -815,6 +835,8 @@ export const useSessionStore = defineStore('session', () => {
|
||||
activeSessionId,
|
||||
isRdpModalOpen, // 导出 RDP 模态框状态
|
||||
rdpConnectionInfo, // 导出 RDP 连接信息
|
||||
isVncModalOpen, // 导出 VNC 模态框状态
|
||||
vncConnectionInfo, // 导出 VNC 连接信息
|
||||
// Getters
|
||||
sessionTabs,
|
||||
sessionTabsWithStatus, // 导出新的 getter
|
||||
@@ -841,6 +863,8 @@ export const useSessionStore = defineStore('session', () => {
|
||||
// --- RDP Modal Actions ---
|
||||
openRdpModal, // 导出打开 RDP 模态框 Action
|
||||
closeRdpModal, // 导出关闭 RDP 模态框 Action
|
||||
openVncModal, // 导出打开 VNC 模态框 Action
|
||||
closeVncModal, // 导出关闭 VNC 模态框 Action
|
||||
// --- 命令输入框 Action ---
|
||||
updateSessionCommandInput, // 导出更新命令输入 Action
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@ interface SettingsState {
|
||||
timezone?: string; // NEW: 时区设置 (e.g., 'Asia/Shanghai', 'UTC')
|
||||
rdpModalWidth?: string; // NEW: RDP 模态框宽度
|
||||
rdpModalHeight?: string; // NEW: RDP 模态框高度
|
||||
vncModalWidth?: string; // NEW: VNC 模态框宽度
|
||||
vncModalHeight?: string; // NEW: VNC 模态框高度
|
||||
ipBlacklistEnabled?: string;
|
||||
dashboardSortBy?: SortField;
|
||||
dashboardSortOrder?: SortOrder;
|
||||
@@ -250,7 +252,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
if (settings.value.rdpModalHeight === undefined) {
|
||||
settings.value.rdpModalHeight = '858';
|
||||
}
|
||||
|
||||
// NEW: VNC Modal Size defaults
|
||||
if (settings.value.vncModalWidth === undefined) {
|
||||
settings.value.vncModalWidth = '1024'; // 默认宽度
|
||||
}
|
||||
if (settings.value.vncModalHeight === undefined) {
|
||||
settings.value.vncModalHeight = '768'; // 默认高度
|
||||
}
|
||||
|
||||
if (settings.value.dashboardSortBy === undefined) {
|
||||
settings.value.dashboardSortBy = 'last_connected_at';
|
||||
}
|
||||
@@ -364,6 +373,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
'timezone', // NEW: 添加时区键
|
||||
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
|
||||
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
|
||||
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
|
||||
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
|
||||
'ipBlacklistEnabled',
|
||||
'dashboardSortBy',
|
||||
'dashboardSortOrder',
|
||||
@@ -451,6 +462,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
'timezone', // NEW: 添加时区键
|
||||
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
|
||||
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
|
||||
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
|
||||
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
|
||||
'ipBlacklistEnabled',
|
||||
'dashboardSortBy',
|
||||
'dashboardSortOrder',
|
||||
|
||||
@@ -320,7 +320,7 @@ const getTagNames = (tagIds: number[] | undefined): string[] => {
|
||||
<li v-for="conn in filteredAndSortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out">
|
||||
<div class="flex-grow mr-4 overflow-hidden">
|
||||
<span class="font-medium block truncate flex items-center" :title="conn.name || ''">
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<i :class="['fas', conn.type === 'VNC' ? 'fa-plug' : (conn.type === 'RDP' ? 'fa-desktop' : 'fa-server'), 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name || t('connections.unnamed') }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
|
||||
|
||||
@@ -10,6 +10,7 @@ import TerminalTabBar from '../components/TerminalTabBar.vue';
|
||||
import LayoutRenderer from '../components/LayoutRenderer.vue';
|
||||
import LayoutConfigurator from '../components/LayoutConfigurator.vue';
|
||||
import RemoteDesktopModal from '../components/RemoteDesktopModal.vue';
|
||||
import VncModal from '../components/VncModal.vue'; // +++ 引入 VncModal 组件 +++
|
||||
import Terminal from '../components/Terminal.vue'; // +++ 引入 Terminal 组件 +++
|
||||
import CommandInputBar from '../components/CommandInputBar.vue'; // +++ 引入 CommandInputBar 组件 +++
|
||||
import VirtualKeyboard from '../components/VirtualKeyboard.vue'; // +++ 引入 VirtualKeyboard 组件 +++
|
||||
@@ -33,7 +34,7 @@ const breakpoints = useBreakpoints(breakpointsTailwind); // +++ 初始化 Breakp
|
||||
const isMobile = breakpoints.smaller('md'); // +++ 定义 isMobile (小于 md 断点) +++
|
||||
|
||||
// --- 从 Store 获取响应式状态和 Getters ---
|
||||
const { sessionTabsWithStatus, activeSessionId, activeSession, isRdpModalOpen, rdpConnectionInfo } = storeToRefs(sessionStore); // 使用 storeToRefs 获取 RDP 状态
|
||||
const { sessionTabsWithStatus, activeSessionId, activeSession, isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // 使用 storeToRefs 获取 RDP 和 VNC 状态
|
||||
const { shareFileEditorTabsBoolean, layoutLockedBoolean } = storeToRefs(settingsStore); // +++ Add layoutLockedBoolean +++
|
||||
const { orderedTabs: globalEditorTabs, activeTabId: globalActiveEditorTabId } = storeToRefs(fileEditorStore);
|
||||
const { layoutTree } = storeToRefs(layoutStore); // 只获取布局树
|
||||
@@ -448,14 +449,13 @@ const handleCloseEditorTab = (tabId: string) => {
|
||||
|
||||
// --- 连接列表操作处理 (用于 WorkspaceConnectionList) ---
|
||||
const handleConnectRequest = (id: number) => {
|
||||
console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`);
|
||||
// +++ 修复:传递 ConnectionInfo 而不是 ID +++
|
||||
const connectionInfo = connectionsStore.connections.find(c => c.id === id);
|
||||
if (connectionInfo) {
|
||||
sessionStore.handleConnectRequest(connectionInfo);
|
||||
} else {
|
||||
console.error(`[WorkspaceView] handleConnectRequest: 未找到 ID 为 ${id} 的连接信息。`);
|
||||
}
|
||||
const connectionInfo = connectionsStore.connections.find(c => c.id === id);
|
||||
// console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`); // 保留原始日志或移除
|
||||
if (connectionInfo) {
|
||||
sessionStore.handleConnectRequest(connectionInfo);
|
||||
} else {
|
||||
console.error(`[WorkspaceView] handleConnectRequest: Connection info not found for ID ${id}.`); // 保留错误日志
|
||||
}
|
||||
};
|
||||
const handleOpenNewSession = (id: number) => {
|
||||
console.log(`[WorkspaceView] Received 'open-new-session' event for ID: ${id}`);
|
||||
@@ -650,11 +650,8 @@ const toggleVirtualKeyboard = () => {
|
||||
@close="handleCloseLayoutConfigurator"
|
||||
/>
|
||||
|
||||
<RemoteDesktopModal
|
||||
v-if="isRdpModalOpen"
|
||||
:connection="rdpConnectionInfo"
|
||||
@close="sessionStore.closeRdpModal()"
|
||||
/>
|
||||
<!-- RDP Modal is now rendered in App.vue -->
|
||||
<!-- VNC Modal is now rendered in App.vue -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Generated
-1610
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
||||
// @ts-ignore - Still need this for the import as no types exist
|
||||
import GuacamoleLite from 'guacamole-lite';
|
||||
import express, { Request, Response } from 'express';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
import cors from 'cors';
|
||||
|
||||
|
||||
// --- 配置 ---
|
||||
const GUAC_WS_PORT = process.env.GUAC_WS_PORT || 8081;
|
||||
const API_PORT = process.env.API_PORT || 9090;
|
||||
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
|
||||
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
|
||||
|
||||
// --- 启动时生成内存加密密钥 ---
|
||||
console.log("正在为此会话生成新的内存加密密钥...");
|
||||
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
|
||||
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
|
||||
console.log("内存加密密钥已生成。");
|
||||
|
||||
// --- Express 应用设置 ---
|
||||
const app = express();
|
||||
const apiServer = http.createServer(app);
|
||||
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
process.env.MAIN_BACKEND_URL || 'http://localhost:3000'
|
||||
];
|
||||
app.use(cors({ origin: allowedOrigins }));
|
||||
|
||||
|
||||
const guacdOptions = {
|
||||
host: GUACD_HOST,
|
||||
port: GUACD_PORT,
|
||||
};
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
host: '0.0.0.0',
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
// 将实际的密钥 Buffer 传递给 guacamole-lite 用于其内部加密操作
|
||||
key: ENCRYPTION_KEY_BUFFER,
|
||||
cypher: 'aes-256-cbc' // 确保加密和解密之间的密码算法一致
|
||||
},
|
||||
// 默认连接设置
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
'security': 'nla',
|
||||
'ignore-cert': 'true',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let guacServer: any;
|
||||
|
||||
try {
|
||||
console.log(`[RDP 服务] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
|
||||
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
|
||||
console.log(`[RDP 服务] GuacamoleLite 初始化成功。`);
|
||||
|
||||
if (guacServer.on) {
|
||||
guacServer.on('error', (error: Error) => {
|
||||
console.error(`[RDP 服务] GuacamoleLite 服务器错误:`, error);
|
||||
});
|
||||
guacServer.on('connection', (client: any) => {
|
||||
const clientId = client.id || '未知客户端ID';
|
||||
console.log(`[RDP 服务] Guacd 连接事件触发。客户端 ID: ${clientId}`);
|
||||
|
||||
|
||||
if (client && typeof client.on === 'function') {
|
||||
client.on('disconnect', (reason: string) => {
|
||||
console.log(`[RDP 服务] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
|
||||
});
|
||||
client.on('error', (err: Error) => {
|
||||
console.error(`[RDP 服务] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
|
||||
});
|
||||
|
||||
client.on('message', (message: Buffer | string) => {
|
||||
// 在回滚状态下移除了消息处理
|
||||
});
|
||||
|
||||
} else {
|
||||
// 对没有 'on' 方法的客户端进行最小化处理
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[RDP 服务] 初始化 GuacamoleLite 失败:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 更新了 encryptToken 以匹配 guacamole-lite 期望的格式 (aes-256-cbc 和特定的 JSON 结构)
|
||||
// 现在直接接受密钥 Buffer 以进行正确的加密操作
|
||||
const encryptToken = (data: string, keyBuffer: Buffer): string => {
|
||||
try {
|
||||
const iv = crypto.randomBytes(16); // AES-CBC 通常使用 16 字节的 IV
|
||||
// 使用密钥 Buffer 进行 Node.js 加密操作
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
// 构建 guacamole-lite 的解密函数期望的 JSON 对象
|
||||
const output = {
|
||||
iv: iv.toString('base64'),
|
||||
value: encrypted
|
||||
};
|
||||
|
||||
// 将 JSON 字符串化,然后对整个字符串进行 Base64 编码
|
||||
const jsonString = JSON.stringify(output);
|
||||
return Buffer.from(jsonString).toString('base64');
|
||||
|
||||
} catch (e) {
|
||||
console.error("令牌加密失败:", e);
|
||||
throw new Error("令牌加密失败。");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app.get('/api/get-token', (req: any, res: any) => {
|
||||
const { hostname, port, username, password, security = 'any', ignoreCert = 'true' } = req.query;
|
||||
|
||||
if (!hostname || !port || !username || typeof password === 'undefined') {
|
||||
return res.status(400).json({ error: '缺少必需的 RDP 参数 (hostname, port, username, password)' });
|
||||
}
|
||||
|
||||
const connectionParams = {
|
||||
connection: {
|
||||
type: 'rdp',
|
||||
settings: {
|
||||
hostname: hostname as string,
|
||||
port: port as string,
|
||||
username: username as string,
|
||||
password: password as string,
|
||||
// 从查询中包含动态(或默认)的大小参数
|
||||
width: String(req.query.width || '1024'),
|
||||
height: String(req.query.height || '768'),
|
||||
dpi: String(req.query.dpi || '96'),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenData = JSON.stringify(connectionParams);
|
||||
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
|
||||
res.json({ token: encryptedToken });
|
||||
} catch (error) {
|
||||
console.error("/api/get-token 接口出错:", error);
|
||||
res.status(500).json({ error: '生成令牌失败' });
|
||||
}
|
||||
});
|
||||
|
||||
apiServer.listen(API_PORT, () => {
|
||||
console.log(`[RDP 服务] API 服务器正在监听端口 ${API_PORT}`);
|
||||
console.log(`[RDP 服务] Guacamole WebSocket 服务器应在端口 ${GUAC_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = (signal: string) => {
|
||||
console.log(`收到 ${signal} 信号。正在优雅地关闭...`);
|
||||
|
||||
let guacClosed = false;
|
||||
let apiClosed = false;
|
||||
|
||||
const tryExit = () => {
|
||||
if (guacClosed && apiClosed) {
|
||||
console.log("所有服务器已关闭。正在退出。");
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
apiServer.close((err) => {
|
||||
if (err) {
|
||||
console.error("关闭 API 服务器时出错:", err);
|
||||
} else {
|
||||
console.log("API 服务器已关闭。");
|
||||
}
|
||||
apiClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
|
||||
// @ts-ignore - 假设基于通用模式存在 close 方法
|
||||
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
|
||||
console.log("正在关闭 Guacamole 服务器..."); // 添加了关闭日志
|
||||
// @ts-ignore
|
||||
guacServer.close(() => {
|
||||
console.log("Guacamole 服务器已关闭。"); // 添加了关闭日志
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
} else {
|
||||
console.log("Guacamole 服务器未运行或不支持 close() 方法。");
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
}
|
||||
|
||||
// 超时后强制退出
|
||||
setTimeout(() => {
|
||||
console.error("关闭超时。强制退出。");
|
||||
process.exit(1);
|
||||
}, 10000); // 10 秒超时
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
process.on('SIGUSR2', () => {
|
||||
gracefulShutdown('SIGUSR2 (nodemon restart)');
|
||||
});
|
||||
@@ -5,14 +5,15 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY packages/rdp/package.json packages/rdp/package-lock.json* ./
|
||||
COPY packages/remote-gateway/package.json packages/remote-gateway/package-lock.json* ./
|
||||
|
||||
# Install ALL dependencies (including devDependencies like typescript)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code and tsconfig
|
||||
COPY packages/rdp/src ./src
|
||||
COPY packages/rdp/tsconfig.json ./tsconfig.json
|
||||
COPY packages/remote-gateway/src ./src
|
||||
COPY packages/remote-gateway/tsconfig.json ./tsconfig.json
|
||||
COPY packages/remote-gateway/guacamole-lite.d.ts ./guacamole-lite.d.ts
|
||||
|
||||
# Build the TypeScript code
|
||||
RUN npm run build
|
||||
@@ -28,7 +29,7 @@ WORKDIR /app
|
||||
# Copy built code and node_modules from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY packages/rdp/package.json ./package.json
|
||||
COPY packages/remote-gateway/package.json ./package.json
|
||||
|
||||
# --- Add patch application steps ---
|
||||
# Copy the patches directory from the build context (relative to project root)
|
||||
@@ -47,6 +48,7 @@ RUN npm uninstall patch-package
|
||||
# --- End patch application steps ---
|
||||
|
||||
# Expose the API and WebSocket ports
|
||||
# These will be configurable via environment variables, but good to have defaults
|
||||
EXPOSE 9090
|
||||
EXPOSE 8081
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@nexus-terminal/rdp",
|
||||
"name": "@nexus-terminal/remote-gateway",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"description": "Unified Remote Desktop Gateway for Nexus Terminal",
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
@@ -27,4 +27,4 @@
|
||||
"guacamole-lite": "^0.7.3",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// @ts-ignore - Still need this for the import as no types exist
|
||||
import GuacamoleLite from 'guacamole-lite';
|
||||
import express, { Request, Response } from 'express';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
import cors from 'cors';
|
||||
|
||||
// --- 配置 ---
|
||||
const REMOTE_GATEWAY_WS_PORT = process.env.REMOTE_GATEWAY_WS_PORT || 8080; // 统一端口,或按需分开
|
||||
const REMOTE_GATEWAY_API_PORT = process.env.REMOTE_GATEWAY_API_PORT || 9090;
|
||||
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
|
||||
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const MAIN_BACKEND_URL = process.env.MAIN_BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
// --- 启动时生成内存加密密钥 ---
|
||||
console.log("[Remote Gateway] 正在为此会话生成新的内存加密密钥...");
|
||||
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
|
||||
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
|
||||
console.log("[Remote Gateway] 内存加密密钥已生成。");
|
||||
|
||||
// --- Express 应用设置 ---
|
||||
const app = express();
|
||||
app.use(express.json()); // 用于解析请求体中的 JSON
|
||||
const apiServer = http.createServer(app);
|
||||
|
||||
const allowedOrigins = [
|
||||
FRONTEND_URL,
|
||||
MAIN_BACKEND_URL
|
||||
];
|
||||
console.log(`[Remote Gateway] CORS 允许的来源: ${allowedOrigins.join(', ')}`);
|
||||
app.use(cors({ origin: allowedOrigins }));
|
||||
|
||||
|
||||
const guacdOptions = {
|
||||
host: GUACD_HOST,
|
||||
port: GUACD_PORT,
|
||||
};
|
||||
|
||||
const websocketOptions = {
|
||||
port: REMOTE_GATEWAY_WS_PORT,
|
||||
host: '0.0.0.0', // 监听所有接口
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
key: ENCRYPTION_KEY_BUFFER,
|
||||
cypher: 'aes-256-cbc'
|
||||
},
|
||||
// 默认连接设置将根据协议动态调整
|
||||
connectionDefaultSettings: {},
|
||||
};
|
||||
|
||||
let guacServer: any;
|
||||
|
||||
try {
|
||||
console.log(`[Remote Gateway] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
|
||||
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
|
||||
console.log(`[Remote Gateway] GuacamoleLite 初始化成功。`);
|
||||
|
||||
if (guacServer.on) {
|
||||
guacServer.on('error', (error: Error) => {
|
||||
console.error(`[Remote Gateway] GuacamoleLite 服务器错误:`, error);
|
||||
});
|
||||
guacServer.on('connection', (client: any) => {
|
||||
const clientId = client.id || '未知客户端ID';
|
||||
console.log(`[Remote Gateway] Guacd 连接事件触发。客户端 ID: ${clientId}`);
|
||||
|
||||
if (client && typeof client.on === 'function') {
|
||||
client.on('disconnect', (reason: string) => {
|
||||
console.log(`[Remote Gateway] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
|
||||
});
|
||||
client.on('error', (err: Error) => {
|
||||
console.error(`[Remote Gateway] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Remote Gateway] 初始化 GuacamoleLite 失败:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const encryptToken = (data: string, keyBuffer: Buffer): string => {
|
||||
try {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
const output = {
|
||||
iv: iv.toString('base64'),
|
||||
value: encrypted
|
||||
};
|
||||
const jsonString = JSON.stringify(output);
|
||||
return Buffer.from(jsonString).toString('base64');
|
||||
} catch (e) {
|
||||
console.error("[Remote Gateway] 令牌加密失败:", e);
|
||||
throw new Error("令牌加密失败。");
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/api/remote-desktop/token', (req: Request, res: Response): void => {
|
||||
const { protocol, connectionConfig } = req.body;
|
||||
|
||||
if (!protocol || !connectionConfig) {
|
||||
res.status(400).json({ error: '缺少必需的参数 (protocol, connectionConfig)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol !== 'rdp' && protocol !== 'vnc') {
|
||||
res.status(400).json({ error: '无效的协议类型。支持 "rdp" 或 "vnc"。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostname, port, username, password, width, height, dpi, security, ignoreCert } = connectionConfig;
|
||||
|
||||
if (!hostname || !port) {
|
||||
res.status(400).json({ error: '缺少必需的连接参数 (hostname, port)' });
|
||||
return;
|
||||
}
|
||||
|
||||
let settings: any = {
|
||||
hostname: hostname as string,
|
||||
port: port as string,
|
||||
width: String(width || '1024'),
|
||||
height: String(height || '768'),
|
||||
};
|
||||
|
||||
if (protocol === 'rdp') {
|
||||
if (typeof username === 'undefined' || typeof password === 'undefined') {
|
||||
res.status(400).json({ error: 'RDP 连接缺少 username 或 password' });
|
||||
return;
|
||||
}
|
||||
settings.username = username as string;
|
||||
settings.password = password as string;
|
||||
settings.security = security || 'any'; // RDP 特有,使用默认值 'any'
|
||||
settings['ignore-cert'] = String(ignoreCert || 'true'); // RDP 特有
|
||||
settings.dpi = String(dpi || '96'); // RDP 特有
|
||||
} else if (protocol === 'vnc') {
|
||||
if (typeof password === 'undefined') {
|
||||
res.status(400).json({ error: 'VNC 连接缺少 password' });
|
||||
return;
|
||||
}
|
||||
settings.password = password as string;
|
||||
if (username) { // VNC 可选 username
|
||||
settings.username = username as string;
|
||||
}
|
||||
// VNC 特有的其他参数可以根据需要从 connectionConfig 中获取并添加
|
||||
// 例如: settings['enable-audio'] = connectionConfig.enableAudio || 'false';
|
||||
}
|
||||
|
||||
const connectionParams = {
|
||||
connection: {
|
||||
type: protocol, // 'rdp' or 'vnc'
|
||||
settings: settings
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenData = JSON.stringify(connectionParams);
|
||||
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
|
||||
res.json({ token: encryptedToken });
|
||||
} catch (error) {
|
||||
console.error("[Remote Gateway] /api/remote-desktop/token 接口出错:", error);
|
||||
res.status(500).json({ error: '生成令牌失败' });
|
||||
}
|
||||
});
|
||||
|
||||
apiServer.listen(REMOTE_GATEWAY_API_PORT, () => {
|
||||
console.log(`[Remote Gateway] API 服务器正在监听端口 ${REMOTE_GATEWAY_API_PORT}`);
|
||||
console.log(`[Remote Gateway] Guacamole WebSocket 服务器应在端口 ${REMOTE_GATEWAY_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = (signal: string) => {
|
||||
console.log(`[Remote Gateway] 收到 ${signal} 信号。正在优雅地关闭...`);
|
||||
|
||||
let guacClosed = false;
|
||||
let apiClosed = false;
|
||||
|
||||
const tryExit = () => {
|
||||
if (guacClosed && apiClosed) {
|
||||
console.log("[Remote Gateway] 所有服务器已关闭。正在退出。");
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
apiServer.close((err) => {
|
||||
if (err) {
|
||||
console.error("[Remote Gateway] 关闭 API 服务器时出错:", err);
|
||||
} else {
|
||||
console.log("[Remote Gateway] API 服务器已关闭。");
|
||||
}
|
||||
apiClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
|
||||
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
|
||||
console.log("[Remote Gateway] 正在关闭 Guacamole 服务器...");
|
||||
guacServer.close(() => {
|
||||
console.log("[Remote Gateway] Guacamole 服务器已关闭。");
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
} else {
|
||||
console.log("[Remote Gateway] Guacamole 服务器未运行或不支持 close() 方法。");
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.error("[Remote Gateway] 关闭超时。强制退出。");
|
||||
process.exit(1);
|
||||
}, 10000); // 10 秒超时
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGUSR2', () => {
|
||||
gracefulShutdown('SIGUSR2 (nodemon restart)');
|
||||
});
|
||||
@@ -114,4 +114,4 @@
|
||||
"src/**/*", // Include all files in the src directory
|
||||
"guacamole-lite.d.ts" // Include the specific .d.ts file
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user