This commit is contained in:
Baobhan Sith
2025-04-27 22:48:24 +08:00
parent 8ad6cfefd4
commit 046fe72d4f
14 changed files with 801 additions and 49 deletions
+1
View File
@@ -15,6 +15,7 @@
"@xterm/addon-search": "^0.15.0",
"axios": "^1.8.4",
"date-fns": "^4.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.52.2",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
@@ -1,61 +1,377 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
// @ts-ignore - guacamole-common-js lacks official types
import Guacamole from 'guacamole-common-js';
import apiClient from '../utils/apiClient'; // 假设 API 客户端路径
import { ConnectionInfo } from '../stores/connections.store'; // 假设 ConnectionInfo 类型路径
const { t } = useI18n();
// Props (可以稍后添加,例如接收连接信息)
// const props = defineProps<{
// connection?: ConnectionInfo; // 假设有 ConnectionInfo 类型
// }>();
// --- Props ---
const props = defineProps<{
connection: ConnectionInfo | null; // 接收连接信息
}>();
// Emits (用于通知父组件关闭模态框)
// --- Emits ---
const emit = defineEmits(['close']);
// --- State Refs ---
const rdpDisplayRef = ref<HTMLDivElement | null>(null); // Guacamole 显示容器
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const guacClient = ref<any | null>(null); // Guacamole 客户端实例 (使用 any 因为类型缺失)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
const statusMessage = ref('');
const keyboard = ref<any | null>(null); // Guacamole Keyboard instance
const mouse = ref<any | null>(null); // Guacamole Mouse instance
// --- Configuration ---
// Configuration for the separate RDP backend service
// TODO: Make these configurable
const RDP_BACKEND_API_BASE = 'http://localhost:9090'; // Default port for test-rdp/packages/rdp API
const RDP_BACKEND_WEBSOCKET_URL = 'ws://localhost:8081'; // Default port for test-rdp/packages/rdp WebSocket
// --- Connection Logic ---
const connectRdp = async () => {
if (!props.connection || !rdpDisplayRef.value) {
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
connectionStatus.value = 'error';
console.error('[RDP Modal] Connection info or display element missing.');
return;
}
// 清理之前的显示内容
while (rdpDisplayRef.value.firstChild) {
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
}
disconnectRdp(); // Ensure any previous connection is cleaned up
connectionStatus.value = 'connecting';
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
try {
// 1. 从独立的 RDP 后端获取 Token
// WARNING: Sending credentials directly like this is insecure if the API is not properly secured (e.g., HTTPS, network isolation).
// WARNING: props.connection likely does NOT contain the password. Using a placeholder.
// You MUST implement a secure way to get the password here.
const connectionParams = new URLSearchParams({
hostname: props.connection.host,
port: props.connection.port.toString(),
username: props.connection.username,
// !!! SECURITY RISK: Password should not be handled like this !!!
// Replace this with a secure method (e.g., prompt user, fetch securely)
password: (props.connection as any).password || 'PASSWORD_PLACEHOLDER', // Assuming password might exist, otherwise use placeholder
security: (props.connection as any).rdp_security || 'any', // Use RDP specific fields if available
ignoreCert: String((props.connection as any).rdp_ignore_cert ?? true),
// Add other necessary params supported by the rdp backend API
});
const apiUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${connectionParams.toString()}`;
console.log(`[RDP Modal] Fetching token from RDP backend: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
// Use fetch directly as apiClient might be configured for the main backend
const response = await fetch(apiUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Failed to parse error response' }));
throw new Error(`RDP API Error (${response.status}): ${errorData.error || response.statusText}`);
}
const data = await response.json();
const token = data.token;
if (!token) {
throw new Error('Token not found in RDP API response');
}
console.log('[RDP Modal] Received token.');
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
// 2. 连接 WebSocket (to RDP backend's WebSocket server)
const tunnelUrl = `${RDP_BACKEND_WEBSOCKET_URL}/?token=${encodeURIComponent(token)}`;
console.log(`[RDP Modal] Connecting WebSocket to: ${RDP_BACKEND_WEBSOCKET_URL}/?token=...`);
// @ts-ignore
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
tunnel.onerror = (status: any) => {
console.error("[RDP Modal Tunnel] Tunnel Error Status:", status);
const errorMessage = status.message || 'Unknown tunnel error';
const errorCode = status.code || 'N/A';
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
connectionStatus.value = 'error';
disconnectRdp(); // Clean up on tunnel error
};
// 3. 创建 Guacamole 客户端
// @ts-ignore
guacClient.value = new Guacamole.Client(tunnel);
// 4. 添加显示元素到 DOM
rdpDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
// 5. 处理客户端状态变化
guacClient.value.onstatechange = (state: number) => {
console.log("[RDP Modal] Guacamole client state changed:", state);
switch (state) {
case 0: // IDLE
statusMessage.value = t('remoteDesktopModal.status.idle');
connectionStatus.value = 'disconnected';
break;
case 1: // CONNECTING
statusMessage.value = t('remoteDesktopModal.status.connectingRdp');
connectionStatus.value = 'connecting';
break;
case 2: // WAITING
statusMessage.value = t('remoteDesktopModal.status.waiting');
connectionStatus.value = 'connecting';
break;
case 3: // CONNECTED
statusMessage.value = t('remoteDesktopModal.status.connected');
connectionStatus.value = 'connected';
setupInputListeners(); // 连接成功后设置输入监听
break;
case 4: // DISCONNECTING
statusMessage.value = t('remoteDesktopModal.status.disconnecting');
connectionStatus.value = 'disconnected';
break;
case 5: // DISCONNECTED
statusMessage.value = t('remoteDesktopModal.status.disconnected');
connectionStatus.value = 'disconnected';
// disconnectRdp(); // State change might already trigger cleanup, avoid double disconnect
break;
default:
statusMessage.value = `${t('remoteDesktopModal.status.unknownState')}: ${state}`;
}
};
// 6. 处理客户端错误
guacClient.value.onerror = (status: any) => {
console.error("[RDP Modal Client] Client Error Status:", status);
const errorMessage = status.message || 'Unknown client error';
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
connectionStatus.value = 'error';
disconnectRdp(); // Clean up on client error
};
// 7. (可选) 处理指令日志
// guacClient.value.oninstruction = (opcode: string, args: any[]) => {
// if (['sync', 'size', 'name', 'error', 'disconnect'].includes(opcode)) {
// console.log(`[RDP Modal Client] Received instruction: ${opcode}`, args);
// }
// };
// 8. 开始连接
console.log("[RDP Modal] Initiating Guacamole client connection...");
guacClient.value.connect();
} catch (error: any) {
console.error("[RDP Modal] Connection failed:", error);
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
connectionStatus.value = 'error';
disconnectRdp(); // Clean up on failure
}
};
// --- Input Handling ---
const setupInputListeners = () => {
if (!guacClient.value || !rdpDisplayRef.value) return;
console.log("[RDP Modal Input] Setting up input listeners...");
try {
const displayEl = guacClient.value.getDisplay().getElement() as HTMLElement;
// --- Mouse ---
// @ts-ignore
mouse.value = new Guacamole.Mouse(displayEl);
// @ts-ignore
mouse.value.onmousedown = mouse.value.onmouseup = mouse.value.onmousemove = (mouseState: any) => {
if (guacClient.value) {
guacClient.value.sendMouseState(mouseState);
}
};
console.log("[RDP Modal Input] Mouse listeners attached.");
// --- Keyboard ---
// @ts-ignore
keyboard.value = new Guacamole.Keyboard(document); // Listen on document for global key events
// Prevent default browser actions for keys handled by Guacamole
// keyboard.value.listenTo(document); // This might interfere with other inputs, attach carefully
keyboard.value.onkeydown = (keysym: number) => {
if (guacClient.value) {
// console.log("[RDP Input] KeyDown:", keysym);
guacClient.value.sendKeyEvent(1, keysym);
}
};
keyboard.value.onkeyup = (keysym: number) => {
if (guacClient.value) {
// console.log("[RDP Input] KeyUp:", keysym);
guacClient.value.sendKeyEvent(0, keysym);
}
};
console.log("[RDP Modal Input] Keyboard listeners attached.");
} catch (inputError) {
console.error("[RDP Modal Input] Error setting up input listeners:", inputError);
statusMessage.value = t('remoteDesktopModal.errors.inputError');
}
};
const removeInputListeners = () => {
console.log("[RDP Modal Input] Removing input listeners...");
if (keyboard.value) {
// If listenTo(document) was used, need a way to remove it,
// otherwise just nullifying the handlers might be enough.
// Guacamole.Keyboard doesn't have an obvious 'stopListening'
keyboard.value.onkeydown = null;
keyboard.value.onkeyup = null;
keyboard.value = null; // Release reference
console.log("[RDP Modal Input] Keyboard listeners removed.");
}
if (mouse.value) {
// Mouse listeners are attached to the display element,
// removing the element itself or nullifying handlers should work.
mouse.value.onmousedown = null;
mouse.value.onmouseup = null;
mouse.value.onmousemove = null;
mouse.value = null; // Release reference
console.log("[RDP Modal Input] Mouse listeners removed.");
}
};
// --- Disconnect Logic ---
const disconnectRdp = () => {
removeInputListeners(); // Remove listeners first
if (guacClient.value) {
console.log("[RDP Modal] Disconnecting Guacamole client.");
guacClient.value.disconnect();
guacClient.value = null;
}
// Clean up display manually if needed
if (rdpDisplayRef.value) {
while (rdpDisplayRef.value.firstChild) {
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
}
}
if (connectionStatus.value !== 'error') { // Don't overwrite error messages
connectionStatus.value = 'disconnected';
statusMessage.value = t('remoteDesktopModal.status.disconnected');
}
};
// --- Modal Close Handler ---
const closeModal = () => {
disconnectRdp(); // Ensure disconnection when modal is closed
emit('close');
};
// --- Lifecycle Hooks ---
onMounted(() => {
// Automatically connect when component mounts if connection is provided
if (props.connection) {
// Use nextTick to ensure the display ref is available
nextTick(() => {
connectRdp();
});
} else {
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
connectionStatus.value = 'error';
}
});
onUnmounted(() => {
// Ensure disconnection on component unmount
disconnectRdp();
});
// Watch for connection prop changes (e.g., if the modal is reused)
watch(() => props.connection, (newConnection, oldConnection) => {
if (newConnection && newConnection.id !== oldConnection?.id) {
console.log('[RDP Modal] Connection prop changed, reconnecting...');
// Use nextTick to ensure the display ref is available after potential v-if changes
nextTick(() => {
connectRdp(); // Connect with the new connection info
});
} else if (!newConnection) {
disconnectRdp(); // Disconnect if connection becomes null
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
connectionStatus.value = 'error';
}
});
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4"> <!-- Changed background class -->
<div class="bg-background text-foreground rounded-lg shadow-xl w-11/12 max-w-4xl h-5/6 flex flex-col overflow-hidden border border-border">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4 backdrop-blur-sm">
<div class="bg-background text-foreground rounded-lg shadow-xl w-11/12 max-w-6xl h-[90%] flex flex-col overflow-hidden border border-border"> <!-- Increased max-width and height -->
<!-- Modal Header -->
<div class="flex items-center justify-between p-4 border-b border-border flex-shrink-0">
<h3 class="text-lg font-semibold">
<!-- 可以根据 props.connection?.name 动态显示标题 -->
{{ t('remoteDesktopModal.titlePlaceholder') }}
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0"> <!-- Reduced padding -->
<h3 class="text-base font-semibold truncate"> <!-- Reduced text size, added 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>
<button
@click="closeModal"
class="text-text-secondary hover:text-foreground transition-colors duration-150"
:title="t('common.close')"
>
<i class="fas fa-times fa-lg"></i>
</button>
</div>
<!-- Modal Body (Placeholder) -->
<div class="flex-grow p-4 overflow-y-auto">
<div class="flex items-center justify-center h-full text-text-secondary text-center">
<div>
<i class="fas fa-desktop fa-3x mb-4"></i>
<p>{{ t('remoteDesktopModal.contentPlaceholder') }}</p>
<!-- 这里将来会是 Guacamole 或其他 RDP 客户端的容器 -->
</div>
<div class="flex items-center space-x-2">
<!-- Status Indicator -->
<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'
}">
{{ connectionStatus }}
</span>
<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>
<!-- Modal Footer (Optional) -->
<!-- <div class="p-4 border-t border-border flex-shrink-0 text-right">
<button @click="closeModal" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark">
{{ t('common.close') }}
</button>
</div> -->
<!-- Modal Body (Guacamole Display Area) -->
<div class="flex-grow relative bg-black"> <!-- Added relative and bg-black -->
<div ref="rdpDisplayRef" class="rdp-display-container w-full h-full">
<!-- Guacamole display will be rendered here -->
</div>
<!-- Loading/Error Overlay -->
<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="connectRdp"
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>
<!-- Modal Footer (Status Bar) -->
<div class="p-2 border-t border-border flex-shrink-0 text-xs text-text-secondary bg-header"> <!-- Reduced padding -->
{{ statusMessage }}
</div>
</div>
</div>
</template>
<style scoped>
/* 如果需要,可以在这里添加特定的样式 */
.rdp-display-container {
/* Ensure the container itself doesn't introduce scrollbars unnecessarily */
overflow: hidden;
position: relative; /* Needed for Guacamole's absolute positioning */
}
/* Guacamole injects its own elements, target them carefully if needed */
.rdp-display-container :deep(div) {
/* Guacamole layers might need relative positioning */
/* position: relative !important; */ /* Avoid !important if possible */
}
.rdp-display-container :deep(canvas) {
/* Ensure canvas scales correctly if needed, Guacamole usually handles this */
/* width: 100%; */
/* height: 100%; */
}
</style>
+24
View File
@@ -751,6 +751,30 @@
"searchPlaceholder": "搜索名称或主机...",
"noResults": "未找到匹配 \"{searchTerm}\" 的连接。"
},
"remoteDesktopModal": {
"title": "远程桌面",
"titlePlaceholder": "远程桌面连接",
"status": {
"fetchingToken": "正在获取连接令牌...",
"connectingWs": "正在连接 WebSocket...",
"idle": "空闲",
"connectingRdp": "正在连接远程桌面...",
"waiting": "等待服务器响应...",
"connected": "已连接",
"disconnecting": "正在断开连接...",
"disconnected": "已断开连接",
"unknownState": "未知状态"
},
"errors": {
"missingInfo": "连接信息或显示元素丢失。",
"tunnelError": "通道错误",
"clientError": "客户端错误",
"connectionFailed": "连接失败",
"inputError": "设置输入监听器时出错。",
"noConnection": "未提供连接信息。",
"tokenError": "获取令牌失败"
}
},
"commandInputBar": {
"placeholder": "在此输入命令后按 Enter 发送到终端...",
"searchPlaceholder": "在终端中搜索...",