diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 62bfcb5..2a0767a 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 99140ae..811bd3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: depends_on: - backend - rdp + - vnc networks: - nexus-terminal-network @@ -14,11 +15,12 @@ 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 + VNC_BACKEND_API_BASE: http://vnc:9091 volumes: - ./data:/app/data networks: @@ -41,6 +43,31 @@ services: - guacd - backend + vnc: + container_name: nexus-vnc + image: nexus-vnc # 确保这个标签与 docker-compose.build.yml 或直接构建命令中的标签一致 + build: + context: ./packages/vnc + dockerfile: Dockerfile + ports: + - "9091:9091" # VNC API port + - "8082:8082" # VNC WebSocket port + environment: + GUACD_HOSTNAME: guacd + GUACD_PORT: 4822 + VNC_PORT: 9091 + VNC_WS_PORT: 8082 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} # 复用 RDP 的密钥 + FRONTEND_URL: ${FRONTEND_URL} + MAIN_BACKEND_URL: ${MAIN_BACKEND_URL} + NODE_ENV: production + restart: unless-stopped + networks: + - nexus-terminal-network + depends_on: + - guacd + - backend + guacd: image: guacamole/guacd:latest container_name: nexus-terminal-guacd diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 4583cdd..853795c 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -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(rdpTokenUrl, { - timeout: 10000 // 设置 10 秒超时 - }); + } catch (error: any) { + console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message - 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 后端获取令牌失败。'); + let statusCode = 500; + let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。'; + + // 检查错误是否来自 GuacamoleService 或其内部的 axios 调用 + if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败')) { + responseMessage = error.message; + // 尝试从错误消息中提取状态码,或者根据消息内容判断 + if (error.message.includes('(状态: 4')) statusCode = 400; // 例如 400, 401, 404 + else if (error.message.includes('(状态: 5')) statusCode = 502; // 例如 500, 502, 503, 504 + else statusCode = 503; // Service Unavailable or other specific error + } else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败')) { + responseMessage = error.message; + statusCode = 400; + } else if (error.message.includes('连接类型必须是 RDP')) { + responseMessage = error.message; + statusCode = 400; + } + // 可以保留对 axios.isAxiosError 的检查,以防 GuacamoleService 抛出未包装的 axios 错误 + // 但理想情况下,GuacamoleService 应该抛出更具体的错误。 + else if (axios.isAxiosError(error)) { + responseMessage = '调用 RDP 后端服务时发生网络或请求错误。'; + if (error.response) { + console.error('[Controller:getRdpSessionToken] RDP backend error response (unhandled by GuacamoleService):', 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 (unhandled by GuacamoleService).'); + responseMessage += ' (无法连接或超时)'; + statusCode = 504; + } + } else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService + responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。'; + } + 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 => { + try { + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; } - const guacamoleToken = rdpResponse.data.token; - console.log(`[Controller:getRdpSessionToken] Received Guacamole token from RDP backend for connection ${connectionId}`); + // 1. 获取连接信息和解密后的凭证 + const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId); + if (!connectionData) { + res.status(404).json({ message: '连接未找到。' }); + return; + } + + const { connection, decryptedPassword } = connectionData; + + // 2. 验证连接类型是否为 VNC + if (connection.type !== 'VNC') { + res.status(400).json({ message: '此连接类型不是 VNC。' }); + return; + } + + // 3. 更新 last_connected_at + 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); + } + + // 4. 验证 VNC 连接是否使用密码认证 (VNC 通常总是需要密码) + 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; + } + + // 5. 调用 GuacamoleService 获取 VNC 令牌 + const guacamoleToken = await GuacamoleService.getVncToken(connection, decryptedPassword); + + console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId}`); + // 6. 将 Guacamole 令牌返回给前端 res.status(200).json({ token: guacamoleToken }); } catch (error: any) { - console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error); + console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message let statusCode = 500; - let message = '获取 RDP 会话令牌时发生内部服务器错误。'; + let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。'; - if (axios.isAxiosError(error)) { - message = '调用 RDP 后端服务时出错。'; - 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 - } 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); - } - } else if (error.message.includes('解密失败')) { - message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。'; + // 检查错误是否来自 GuacamoleService 或其内部的 axios 调用 + if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 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('密码解密失败')) { + responseMessage = error.message; + statusCode = 400; + } else if (error.message.includes('连接类型必须是 VNC')) { + responseMessage = error.message; + statusCode = 400; } - - res.status(statusCode).json({ message }); + // 可以保留对 axios.isAxiosError 的检查 + else if (axios.isAxiosError(error)) { + responseMessage = '调用 VNC 后端服务时发生网络或请求错误。'; + if (error.response) { + console.error('[Controller:getVncSessionToken] VNC backend error response (unhandled by GuacamoleService):', 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 VNC backend (unhandled by GuacamoleService).'); + responseMessage += ' (无法连接或超时)'; + statusCode = 504; + } + } else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService + responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。'; + } + res.status(statusCode).json({ message: responseMessage }); } }; - /** * 克隆连接 (POST /api/v1/connections/:id/clone) */ diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index 5098d6c..f1cfd78 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -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); diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index b9f3be6..aa153ab 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -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, diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index 3e652c3..0055fb8 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -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; diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts index 16b2730..ef7ddf6 100644 --- a/packages/backend/src/services/connection.service.ts +++ b/packages/backend/src/services/connection.service.ts @@ -43,9 +43,9 @@ export const createConnection = async (input: CreateConnectionInput): Promise> = {}; 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. 如果有更改,则更新连接记录 diff --git a/packages/backend/src/services/guacamole.service.ts b/packages/backend/src/services/guacamole.service.ts index ac999de..e5b125b 100644 --- a/packages/backend/src/services/guacamole.service.ts +++ b/packages/backend/src/services/guacamole.service.ts @@ -1 +1,110 @@ -// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package. \ No newline at end of file +import axios from 'axios'; +import { ConnectionWithTags } from '../types/connection.types'; + +// RDP 后端服务的 Base URL,从环境变量读取,提供默认值 +const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://nexus-rdp:9090'; // 假设 RDP 服务名为 nexus-rdp + +// VNC 后端服务的 Base URL,从环境变量读取,提供默认值 +const VNC_BACKEND_API_BASE = process.env.VNC_BACKEND_API_BASE || 'http://nexus-vnc:9091'; // 假设 VNC 服务名为 nexus-vnc,端口为 9091 + +/** + * 从 RDP 后端服务获取 Guacamole 令牌 + * @param connection 连接对象 + * @param decryptedPassword 解密后的密码 + * @returns Guacamole 令牌 + */ +export const getRdpToken = async (connection: ConnectionWithTags, decryptedPassword?: string): Promise => { + if (connection.type !== 'RDP') { + throw new Error('连接类型必须是 RDP。'); + } + if (connection.auth_method !== 'password' || !decryptedPassword) { + console.warn(`[GuacamoleService:getRdpToken] RDP connection ${connection.id} does not use password auth or password decryption failed.`); + throw new Error('RDP 连接需要使用密码认证,或密码解密失败。'); + } + + const rdpApiParams = new URLSearchParams({ + hostname: connection.host, + port: connection.port.toString(), + username: connection.username, + password: decryptedPassword, + // 确保传递 RDP 特定的参数,如果存在的话 + security: (connection as any).rdp_security || 'any', // 从连接对象中获取,如果存在 + ignoreCert: String((connection as any).rdp_ignore_cert ?? true), // 从连接对象中获取,如果存在 + // 可以根据需要添加更多参数,例如 domain, width, height, dpi 等 + }); + const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`; + + console.log(`[GuacamoleService:getRdpToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?... for connection ${connection.id}`); + + try { + const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, { + timeout: 10000 // 10 秒超时 + }); + + if (rdpResponse.status !== 200 || !rdpResponse.data?.token) { + console.error(`[GuacamoleService:getRdpToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data); + throw new Error('从 RDP 后端获取令牌失败。'); + } + console.log(`[GuacamoleService:getRdpToken] Received Guacamole token from RDP backend for connection ${connection.id}`); + return rdpResponse.data.token; + } catch (error: any) { + console.error(`[GuacamoleService:getRdpToken] Error calling RDP backend for connection ${connection.id}:`, error.message); + if (axios.isAxiosError(error) && error.response) { + throw new Error(`调用 RDP 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`); + } + throw new Error(`调用 RDP 后端服务时发生错误: ${error.message}`); + } +}; + +/** + * 从 VNC 后端服务获取 Guacamole 令牌 + * @param connection 连接对象 + * @param decryptedPassword 解密后的密码 (VNC 通常需要密码) + * @returns Guacamole 令牌 + */ +export const getVncToken = async (connection: ConnectionWithTags, decryptedPassword?: string): Promise => { + if (connection.type !== 'VNC') { + throw new Error('连接类型必须是 VNC。'); + } + // VNC 通常总是需要密码,并且 auth_method 应该被设置为 'password' + if (connection.auth_method !== 'password' || !decryptedPassword) { + console.warn(`[GuacamoleService:getVncToken] VNC connection ${connection.id} does not use password auth or password decryption failed.`); + throw new Error('VNC 连接需要使用密码认证,或密码解密失败。'); + } + + const vncApiParams = new URLSearchParams({ + hostname: connection.host, + port: connection.port.toString(), + password: decryptedPassword, // VNC 通常只需要密码 + // VNC 特有的参数可以根据 @nexus-terminal/vnc 的 API 进行添加 + // 例如: username (如果 VNC 服务器需要), colorDepth, etc. + // username: connection.username, // 如果 VNC 服务支持用户名 + }); + + // 如果 VNC 服务也支持用户名,可以取消注释上面的 username 参数 + // 注意:标准的 VNC 协议主要通过密码进行认证,用户名不是标准部分,但某些实现可能支持。 + // 这里假设 @nexus-terminal/vnc 的 /api/get-vnc-token 接受这些参数。 + + const vncTokenUrl = `${VNC_BACKEND_API_BASE}/api/get-vnc-token?${vncApiParams.toString()}`; + + console.log(`[GuacamoleService:getVncToken] Calling VNC backend API: ${VNC_BACKEND_API_BASE}/api/get-vnc-token?... for connection ${connection.id}`); + + try { + const vncResponse = await axios.get<{ token: string }>(vncTokenUrl, { + timeout: 10000 // 10 秒超时 + }); + + if (vncResponse.status !== 200 || !vncResponse.data?.token) { + console.error(`[GuacamoleService:getVncToken] VNC backend API call failed or returned invalid data. Status: ${vncResponse.status}`, vncResponse.data); + throw new Error('从 VNC 后端获取令牌失败。'); + } + console.log(`[GuacamoleService:getVncToken] Received Guacamole token from VNC backend for connection ${connection.id}`); + return vncResponse.data.token; + } catch (error: any) { + console.error(`[GuacamoleService:getVncToken] Error calling VNC backend for connection ${connection.id}:`, error.message); + if (axios.isAxiosError(error) && error.response) { + throw new Error(`调用 VNC 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`); + } + throw new Error(`调用 VNC 后端服务时发生错误: ${error.message}`); + } +}; \ No newline at end of file diff --git a/packages/backend/src/types/connection.types.ts b/packages/backend/src/types/connection.types.ts index 34c8af3..9f0e0de 100644 --- a/packages/backend/src/types/connection.types.ts +++ b/packages/backend/src/types/connection.types.ts @@ -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; diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index f403f6d..68ccf51 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -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;"> + @@ -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" /> --> + + + + - +

{{ t('connections.form.sectionAdvanced', '高级选项') }}

-
+