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('&')
|
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() {
|
export function useRdpSession() {
|
||||||
const status = ref<RdpSessionStatus>('idle')
|
const status = ref<RdpSessionStatus>('idle')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -71,6 +75,18 @@ export function useRdpSession() {
|
|||||||
client.sendSize(w, h)
|
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) {
|
async function connect(hostId: number, container: HTMLElement) {
|
||||||
disconnect()
|
disconnect()
|
||||||
mountTarget = container
|
mountTarget = container
|
||||||
@@ -96,32 +112,27 @@ export function useRdpSession() {
|
|||||||
serverName.value = info.name
|
serverName.value = info.name
|
||||||
status.value = 'connecting'
|
status.value = 'connecting'
|
||||||
|
|
||||||
// Token in path: Guacamole Client.connect() appends ?hostname=… to tunnel URL.
|
|
||||||
const wsUrl = buildWebSocketUrl(
|
const wsUrl = buildWebSocketUrl(
|
||||||
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
|
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
|
||||||
)
|
)
|
||||||
tunnel = new Guacamole.WebSocketTunnel(wsUrl)
|
const rdpTunnel = new Guacamole.WebSocketTunnel(wsUrl)
|
||||||
const rdpClient = new Guacamole.Client(tunnel)
|
tunnel = rdpTunnel
|
||||||
|
const rdpClient = new Guacamole.Client(rdpTunnel)
|
||||||
client = rdpClient
|
client = rdpClient
|
||||||
|
|
||||||
rdpClient.onerror = (st: { code?: number; message?: string }) => {
|
const fail = (message: string) => {
|
||||||
status.value = 'error'
|
status.value = 'error'
|
||||||
errorMessage.value = st?.message || `RDP 错误 (${st?.code ?? 'unknown'})`
|
errorMessage.value = message
|
||||||
disconnect()
|
disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rdpTunnel.onerror = (st) => fail(tunnelErrorMessage(st))
|
||||||
|
rdpClient.onerror = (st) => fail(tunnelErrorMessage(st))
|
||||||
|
|
||||||
rdpClient.onstatechange = (state: number) => {
|
rdpClient.onstatechange = (state: number) => {
|
||||||
if (state === Guacamole.Client.STATE_CONNECTED) {
|
if (state === Guacamole.Client.STATE_CONNECTED) {
|
||||||
status.value = 'connected'
|
status.value = 'connected'
|
||||||
const el = rdpClient.getDisplay().getElement()
|
sendDisplaySize()
|
||||||
if (el && mountTarget) {
|
|
||||||
mountTarget.innerHTML = ''
|
|
||||||
mountTarget.appendChild(el)
|
|
||||||
displayEl.value = el
|
|
||||||
sendDisplaySize()
|
|
||||||
resizeObserver = new ResizeObserver(() => sendDisplaySize())
|
|
||||||
resizeObserver.observe(mountTarget)
|
|
||||||
}
|
|
||||||
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
|
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
|
||||||
if (status.value !== 'error') status.value = 'disconnected'
|
if (status.value !== 'error') status.value = 'disconnected'
|
||||||
}
|
}
|
||||||
@@ -130,6 +141,8 @@ export function useRdpSession() {
|
|||||||
const display = rdpClient.getDisplay()
|
const display = rdpClient.getDisplay()
|
||||||
display.onresize = () => sendDisplaySize()
|
display.onresize = () => sendDisplaySize()
|
||||||
|
|
||||||
|
// Display must be in DOM before connect so sync/flush can complete (Guacamole convention).
|
||||||
|
mountDisplay(rdpClient)
|
||||||
rdpClient.connect(buildConnectString(info))
|
rdpClient.connect(buildConnectString(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,16 +84,22 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="status === 'loading' || status === 'connecting'" class="rdp-placeholder">
|
<template v-else>
|
||||||
<v-progress-circular indeterminate color="primary" size="40" />
|
<div ref="canvasHost" class="rdp-canvas-host" />
|
||||||
<p class="text-medium-emphasis mt-3">{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}</p>
|
<div
|
||||||
</div>
|
v-if="status === 'loading' || status === 'connecting'"
|
||||||
<div v-else-if="status === 'error'" class="rdp-placeholder">
|
class="rdp-overlay rdp-placeholder"
|
||||||
<v-alert type="error" variant="tonal" max-width="480">
|
>
|
||||||
{{ errorMessage }}
|
<v-progress-circular indeterminate color="primary" size="40" />
|
||||||
</v-alert>
|
<p class="text-medium-emphasis mt-3">{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}</p>
|
||||||
<v-btn variant="flat" color="primary" class="mt-3" @click="backToList">返回列表</v-btn>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<RdpHostFormDialog
|
<RdpHostFormDialog
|
||||||
@@ -120,7 +126,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import RdpHostFormDialog from '@/components/rdp/RdpHostFormDialog.vue'
|
import RdpHostFormDialog from '@/components/rdp/RdpHostFormDialog.vue'
|
||||||
import { useRdpSession } from '@/composables/rdp/useRdpSession'
|
import { useRdpSession } from '@/composables/rdp/useRdpSession'
|
||||||
@@ -136,6 +142,7 @@ defineOptions({ name: 'RdpPage' })
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const displayHost = ref<HTMLElement | null>(null)
|
const displayHost = ref<HTMLElement | null>(null)
|
||||||
|
const canvasHost = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const { status, errorMessage, serverName, connect, disconnect } = useRdpSession()
|
const { status, errorMessage, serverName, connect, disconnect } = useRdpSession()
|
||||||
const {
|
const {
|
||||||
@@ -254,8 +261,10 @@ async function doDelete() {
|
|||||||
|
|
||||||
async function startIfReady() {
|
async function startIfReady() {
|
||||||
const id = hostId.value
|
const id = hostId.value
|
||||||
const host = displayHost.value
|
if (!id) return
|
||||||
if (!id || !host) return
|
await nextTick()
|
||||||
|
const host = canvasHost.value
|
||||||
|
if (!host) return
|
||||||
await connect(id, host)
|
await connect(id, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,11 +296,25 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
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;
|
display: block;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rdp-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
.rdp-list-panel,
|
.rdp-list-panel,
|
||||||
.rdp-placeholder {
|
.rdp-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user