fix(rdp): connect 前挂载画布并修复 RdpPage 布局

Guacamole display 需在 DOM 中才能完成 sync;canvasHost 独立挂载避免 Vue 重绘清除画布。
This commit is contained in:
Nexus Agent
2026-06-10 21:45:54 +08:00
parent aeca2fd7cb
commit af47b24450
3 changed files with 84 additions and 28 deletions
@@ -0,0 +1,20 @@
# 2026-06-10 — RDP 画布挂载与前端同步修复
## 摘要
修复 3389 页一直「正在连接」:Guacamole 画布需在 `connect()` 前挂入 DOM;连接层用独立 `canvasHost` + 遮罩,避免 Vue 重绘抹掉画布;生产同步 `web/app` 进容器。
## 动机
- 官方 Guacamole 用法:先 `appendChild(display.getElement())``client.connect()`
- 原实现等 `STATE_CONNECTED` 才挂载,sync/flush 可能无法完成,客户端停在 WAITING
- Docker 镜像内前端为旧 vite 缓存,未执行 `sync_webapp_to_container`
## 涉及文件
- `frontend/src/composables/rdp/useRdpSession.ts`
- `frontend/src/pages/RdpPage.vue`
## 验证
生产 `sync_webapp_to_container.sh` 后,3389 连接应进入「已连接」并显示桌面。
+27 -14
View File
@@ -30,6 +30,10 @@ function buildConnectString(info: RdpConnectInfo): string {
return pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
}
function tunnelErrorMessage(st: { code?: number; message?: string }): string {
return st?.message || `RDP 隧道错误 (${st?.code ?? 'unknown'})`
}
export function useRdpSession() {
const status = ref<RdpSessionStatus>('idle')
const errorMessage = ref('')
@@ -71,6 +75,18 @@ export function useRdpSession() {
client.sendSize(w, h)
}
function mountDisplay(rdpClient: InstanceType<typeof Guacamole.Client>) {
if (!mountTarget) return
mountTarget.innerHTML = ''
const el = rdpClient.getDisplay().getElement()
mountTarget.appendChild(el)
displayEl.value = el
resizeObserver?.disconnect()
resizeObserver = new ResizeObserver(() => sendDisplaySize())
resizeObserver.observe(mountTarget)
sendDisplaySize()
}
async function connect(hostId: number, container: HTMLElement) {
disconnect()
mountTarget = container
@@ -96,32 +112,27 @@ export function useRdpSession() {
serverName.value = info.name
status.value = 'connecting'
// Token in path: Guacamole Client.connect() appends ?hostname=… to tunnel URL.
const wsUrl = buildWebSocketUrl(
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
)
tunnel = new Guacamole.WebSocketTunnel(wsUrl)
const rdpClient = new Guacamole.Client(tunnel)
const rdpTunnel = new Guacamole.WebSocketTunnel(wsUrl)
tunnel = rdpTunnel
const rdpClient = new Guacamole.Client(rdpTunnel)
client = rdpClient
rdpClient.onerror = (st: { code?: number; message?: string }) => {
const fail = (message: string) => {
status.value = 'error'
errorMessage.value = st?.message || `RDP 错误 (${st?.code ?? 'unknown'})`
errorMessage.value = message
disconnect()
}
rdpTunnel.onerror = (st) => fail(tunnelErrorMessage(st))
rdpClient.onerror = (st) => fail(tunnelErrorMessage(st))
rdpClient.onstatechange = (state: number) => {
if (state === Guacamole.Client.STATE_CONNECTED) {
status.value = 'connected'
const el = rdpClient.getDisplay().getElement()
if (el && mountTarget) {
mountTarget.innerHTML = ''
mountTarget.appendChild(el)
displayEl.value = el
sendDisplaySize()
resizeObserver = new ResizeObserver(() => sendDisplaySize())
resizeObserver.observe(mountTarget)
}
sendDisplaySize()
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
if (status.value !== 'error') status.value = 'disconnected'
}
@@ -130,6 +141,8 @@ export function useRdpSession() {
const display = rdpClient.getDisplay()
display.onresize = () => sendDisplaySize()
// Display must be in DOM before connect so sync/flush can complete (Guacamole convention).
mountDisplay(rdpClient)
rdpClient.connect(buildConnectString(info))
}
+37 -14
View File
@@ -84,16 +84,22 @@
</v-card>
</div>
<div v-else-if="status === 'loading' || status === 'connecting'" class="rdp-placeholder">
<v-progress-circular indeterminate color="primary" size="40" />
<p class="text-medium-emphasis mt-3">{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}</p>
</div>
<div v-else-if="status === 'error'" class="rdp-placeholder">
<v-alert type="error" variant="tonal" max-width="480">
{{ errorMessage }}
</v-alert>
<v-btn variant="flat" color="primary" class="mt-3" @click="backToList">返回列表</v-btn>
</div>
<template v-else>
<div ref="canvasHost" class="rdp-canvas-host" />
<div
v-if="status === 'loading' || status === 'connecting'"
class="rdp-overlay rdp-placeholder"
>
<v-progress-circular indeterminate color="primary" size="40" />
<p class="text-medium-emphasis mt-3">{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}</p>
</div>
<div v-else-if="status === 'error'" class="rdp-overlay rdp-placeholder">
<v-alert type="error" variant="tonal" max-width="480">
{{ errorMessage }}
</v-alert>
<v-btn variant="flat" color="primary" class="mt-3" @click="backToList">返回列表</v-btn>
</div>
</template>
</div>
<RdpHostFormDialog
@@ -120,7 +126,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RdpHostFormDialog from '@/components/rdp/RdpHostFormDialog.vue'
import { useRdpSession } from '@/composables/rdp/useRdpSession'
@@ -136,6 +142,7 @@ defineOptions({ name: 'RdpPage' })
const route = useRoute()
const router = useRouter()
const displayHost = ref<HTMLElement | null>(null)
const canvasHost = ref<HTMLElement | null>(null)
const { status, errorMessage, serverName, connect, disconnect } = useRdpSession()
const {
@@ -254,8 +261,10 @@ async function doDelete() {
async function startIfReady() {
const id = hostId.value
const host = displayHost.value
if (!id || !host) return
if (!id) return
await nextTick()
const host = canvasHost.value
if (!host) return
await connect(id, host)
}
@@ -287,11 +296,25 @@ onMounted(async () => {
overflow: hidden;
}
.rdp-display-host :deep(canvas) {
.rdp-canvas-host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rdp-canvas-host :deep(canvas) {
display: block;
margin: 0 auto;
}
.rdp-overlay {
position: absolute;
inset: 0;
z-index: 2;
background: #1a1a1a;
}
.rdp-list-panel,
.rdp-placeholder {
display: flex;