fix(rdp): connect 前挂载画布并修复 RdpPage 布局
Guacamole display 需在 DOM 中才能完成 sync;canvasHost 独立挂载避免 Vue 重绘清除画布。
This commit is contained in:
@@ -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 连接应进入「已连接」并显示桌面。
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user