7adc7c00ab
Enable managed remote browser custom pages with login autofill and add website sync workflows so external admin surfaces can be handled inside SmartUp. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1010 lines
29 KiB
Vue
1010 lines
29 KiB
Vue
<template>
|
||
<div class="viewer-wrap" :class="{ embedded }">
|
||
<!-- Toolbar -->
|
||
<div class="viewer-bar">
|
||
<el-button v-if="!embedded" size="small" text @click="router.back()">
|
||
<el-icon><ArrowLeft /></el-icon> 返回
|
||
</el-button>
|
||
<div class="viewer-title">
|
||
<el-icon><component :is="pageIcon" /></el-icon>
|
||
<span>{{ page?.name || '...' }}</span>
|
||
<el-tag v-if="page?.access_mode === 'proxy' || page?.use_proxy" size="small" type="warning" style="margin-left:4px">代理</el-tag>
|
||
<el-tag v-else-if="page?.access_mode === 'remote_browser'" size="small" type="success" style="margin-left:4px">远程浏览器</el-tag>
|
||
</div>
|
||
<div class="viewer-url">{{ remoteSession?.url || page?.url }}</div>
|
||
<div class="viewer-actions">
|
||
<el-tooltip v-if="isRemoteBrowser" content="后退">
|
||
<el-button size="small" text @click="sendRemoteCommand('back')">
|
||
<el-icon><Back /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip v-if="isRemoteBrowser" content="前进">
|
||
<el-button size="small" text @click="sendRemoteCommand('forward')">
|
||
<el-icon><Right /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="在新标签页打开">
|
||
<el-button size="small" text @click="openExternal">
|
||
<el-icon><TopRight /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="刷新">
|
||
<el-button size="small" text @click="reload">
|
||
<el-icon><Refresh /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="viewer-body">
|
||
<div v-if="isRemoteBrowser" class="viewer-content viewer-content-remote">
|
||
<div class="viewer-stage" :class="{ 'viewer-stage-error': showRemoteError }">
|
||
<div
|
||
v-if="!showRemoteError"
|
||
ref="remoteFrameRef"
|
||
class="remote-frame"
|
||
tabindex="0"
|
||
@click="focusRemoteFrame"
|
||
@keydown="onRemoteKeydown"
|
||
@paste.prevent="onRemotePaste"
|
||
@wheel.prevent="onRemoteWheel"
|
||
@pointermove="onRemotePointerMove"
|
||
@pointerup="onRemotePointerUp"
|
||
@pointercancel="onRemotePointerCancel"
|
||
@lostpointercapture="onRemotePointerCancel"
|
||
@dblclick="onRemoteDblClick"
|
||
@focus="remoteFrameFocused = true"
|
||
@blur="remoteFrameFocused = false"
|
||
>
|
||
<div
|
||
v-if="remoteFrameFocused && remoteCaret"
|
||
class="remote-caret"
|
||
:style="{ left: remoteCaret.x + 'px', top: remoteCaret.y + 'px', height: remoteCaret.h + 'px' }"
|
||
/>
|
||
<img
|
||
v-if="remoteScreenshotUrl"
|
||
:src="remoteScreenshotUrl"
|
||
class="remote-screen"
|
||
alt=""
|
||
draggable="false"
|
||
@load="() => { iframeLoading = false }"
|
||
@error="() => handleRemoteSessionFailure(undefined, '远程浏览器截图加载失败')"
|
||
@pointerdown.stop.prevent="onRemotePointerDown"
|
||
@pointermove.stop.prevent="onRemotePointerMove"
|
||
@pointerup.stop.prevent="onRemotePointerUp"
|
||
@pointercancel.stop.prevent="onRemotePointerCancel"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="iframeLoading && !showRemoteError" class="stage-overlay stage-loading">
|
||
<el-icon class="spin" :size="32"><Loading /></el-icon>
|
||
<p>{{ isReconnectingRemoteBrowser ? '正在重连远程浏览器…' : '正在加载页面…' }}</p>
|
||
</div>
|
||
|
||
<div v-else-if="showRemoteError && remoteErrorState" class="remote-error-shell">
|
||
<div class="remote-error-card">
|
||
<div class="remote-error-copy">
|
||
<span class="remote-error-kicker">远程浏览器</span>
|
||
<el-icon class="error-icon" :size="36"><Warning /></el-icon>
|
||
<div class="error-title">{{ remoteErrorState.title }}</div>
|
||
<p class="error-description">{{ remoteErrorState.description }}</p>
|
||
<p class="error-hint">{{ remoteErrorState.hint }}</p>
|
||
<p v-if="remoteErrorState.technicalDetail" class="error-detail">
|
||
技术原因:{{ remoteErrorState.technicalDetail }}
|
||
</p>
|
||
</div>
|
||
<div class="remote-error-actions">
|
||
<el-button type="primary" @click="retryRemoteBrowser()">
|
||
{{ remoteErrorState.actionLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="viewer-content viewer-content-iframe">
|
||
<div v-if="iframeLoading" class="iframe-loading">
|
||
<el-icon class="spin" :size="32"><Loading /></el-icon>
|
||
<p>正在加载页面…</p>
|
||
</div>
|
||
<iframe
|
||
v-show="!iframeLoading"
|
||
ref="iframeRef"
|
||
:src="iframeSrc"
|
||
class="page-iframe"
|
||
@load="onLoad"
|
||
@error="onError"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning,
|
||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
||
} from '@element-plus/icons-vue'
|
||
import { browserSessionsApi, customPagesApi, type BrowserEventPayload, type BrowserSessionData, type CustomPageData } from '@/api'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const props = withDefaults(defineProps<{
|
||
pageId?: number
|
||
active?: boolean
|
||
embedded?: boolean
|
||
}>(), {
|
||
active: true,
|
||
embedded: false,
|
||
})
|
||
|
||
const iconMap: Record<string, any> = {
|
||
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
|
||
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
|
||
Grid: markRaw(Grid), Connection: markRaw(Connection), Ticket: markRaw(Ticket),
|
||
Wallet: markRaw(Wallet), Key: markRaw(Key), Tools: markRaw(Tools),
|
||
Star: markRaw(Star), House: markRaw(House),
|
||
}
|
||
|
||
const page = ref<CustomPageData | null>(null)
|
||
const iframeRef = ref<HTMLIFrameElement>()
|
||
const remoteFrameRef = ref<HTMLDivElement>()
|
||
const iframeLoading = ref(true)
|
||
const remoteSession = ref<BrowserSessionData | null>(null)
|
||
const remoteScreenshotUrl = ref('')
|
||
const isStartingRemoteBrowser = ref(false)
|
||
const isReconnectingRemoteBrowser = ref(false)
|
||
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
|
||
let startRemoteBrowserPromise: Promise<void> | null = null
|
||
let screenshotObjectUrl = ''
|
||
let mouseMoveTimer: number | undefined
|
||
let caretHideTimer: number | undefined
|
||
|
||
// Caret / cursor overlay
|
||
const remoteFrameFocused = ref(false)
|
||
const remoteCaret = ref<{ x: number; y: number; h: number } | null>(null)
|
||
const remotePointer = ref<{
|
||
id: number
|
||
button: 'left' | 'right' | 'middle'
|
||
dragging: boolean
|
||
lastMoveAt: number
|
||
} | null>(null)
|
||
|
||
// WebSocket state
|
||
let ws: WebSocket | null = null
|
||
let wsReconnectTimer: number | undefined
|
||
let wsReconnectAttempts = 0
|
||
const WS_MAX_RECONNECT = 5
|
||
const WS_RECONNECT_BASE_MS = 800
|
||
const REMOTE_DRAG_MOVE_INTERVAL_MS = 16
|
||
const REMOTE_HOVER_MOVE_INTERVAL_MS = 80
|
||
|
||
type RemoteBrowserErrorState = {
|
||
title: string
|
||
description: string
|
||
hint: string
|
||
actionLabel: string
|
||
technicalDetail: string
|
||
}
|
||
|
||
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
||
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
|
||
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
|
||
const embedded = computed(() => props.embedded)
|
||
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
|
||
|
||
// Build the iframe src: use backend proxy if use_proxy=true
|
||
// Pass JWT token as query param since iframe can't set Authorization header
|
||
const iframeSrc = computed(() => {
|
||
if (!page.value) return ''
|
||
if (page.value.access_mode === 'proxy' || page.value.use_proxy) {
|
||
const token = encodeURIComponent(auth.token || '')
|
||
return `/api/custom-pages/${page.value.id}/proxy/?token=${token}`
|
||
}
|
||
return page.value.url
|
||
})
|
||
|
||
async function loadPage(id: number) {
|
||
iframeLoading.value = true
|
||
clearRemoteError()
|
||
try {
|
||
const res = await customPagesApi.list()
|
||
page.value = res.data.find(p => p.id === id) || null
|
||
if (!page.value) {
|
||
ElMessage.error('页面不存在')
|
||
router.push('/custom-pages')
|
||
return
|
||
}
|
||
if (isRemoteBrowser.value) {
|
||
await startRemoteBrowser()
|
||
}
|
||
} catch {
|
||
ElMessage.error('加载失败')
|
||
}
|
||
}
|
||
|
||
function onLoad() {
|
||
iframeLoading.value = false
|
||
}
|
||
|
||
function onError() {
|
||
iframeLoading.value = false
|
||
}
|
||
|
||
function openExternal() {
|
||
if (page.value?.url) window.open(page.value.url, '_blank', 'noopener')
|
||
}
|
||
|
||
function reload() {
|
||
if (!page.value) return
|
||
if (isRemoteBrowser.value) {
|
||
if (remoteErrorState.value || !remoteSession.value) {
|
||
retryRemoteBrowser()
|
||
return
|
||
}
|
||
sendRemoteCommand('reload')
|
||
return
|
||
}
|
||
if (!iframeRef.value) return
|
||
iframeLoading.value = true
|
||
iframeRef.value.src = iframeSrc.value
|
||
}
|
||
|
||
async function startRemoteBrowser(options: { reconnect?: boolean } = {}) {
|
||
if (startRemoteBrowserPromise) return startRemoteBrowserPromise
|
||
startRemoteBrowserPromise = doStartRemoteBrowser(options).finally(() => {
|
||
startRemoteBrowserPromise = null
|
||
})
|
||
return startRemoteBrowserPromise
|
||
}
|
||
|
||
async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||
if (!page.value || !remoteFrameRef.value) {
|
||
await nextTick()
|
||
}
|
||
if (!page.value) return
|
||
isStartingRemoteBrowser.value = true
|
||
isReconnectingRemoteBrowser.value = !!options.reconnect
|
||
iframeLoading.value = true
|
||
clearRemoteError()
|
||
await nextTick()
|
||
stopRemoteWs()
|
||
await closeRemoteSession()
|
||
try {
|
||
const viewport = remoteViewport()
|
||
const res = await browserSessionsApi.create({
|
||
custom_page_id: page.value.id,
|
||
width: viewport.width,
|
||
height: viewport.height,
|
||
})
|
||
remoteSession.value = res.data
|
||
if (!options.reconnect) wsReconnectAttempts = 0
|
||
if (props.active) {
|
||
connectRemoteWs()
|
||
} else {
|
||
iframeLoading.value = false
|
||
}
|
||
await nextTick()
|
||
remoteFrameRef.value?.focus()
|
||
} catch (e: any) {
|
||
setRemoteError(e.response?.status, e.response?.data?.detail || '无法启动服务端 Chromium')
|
||
} finally {
|
||
isStartingRemoteBrowser.value = false
|
||
isReconnectingRemoteBrowser.value = false
|
||
}
|
||
}
|
||
|
||
/** Connect (or reconnect) WebSocket for the current session. */
|
||
function connectRemoteWs() {
|
||
if (!remoteSession.value || !props.active) return
|
||
stopRemoteWs()
|
||
|
||
const sessionId = remoteSession.value.id
|
||
const url = browserSessionsApi.wsUrl(sessionId, auth.token)
|
||
const socket = new WebSocket(url)
|
||
ws = socket
|
||
socket.binaryType = 'blob'
|
||
|
||
socket.onopen = () => {
|
||
wsReconnectAttempts = 0
|
||
}
|
||
|
||
socket.onmessage = (evt) => {
|
||
if (evt.data instanceof Blob) {
|
||
// Binary frame = JPEG screenshot
|
||
const newUrl = URL.createObjectURL(evt.data)
|
||
setRemoteScreenshotUrl(newUrl)
|
||
iframeLoading.value = false
|
||
return
|
||
}
|
||
// Text frame = JSON control message
|
||
try {
|
||
const msg = JSON.parse(evt.data as string)
|
||
if (msg.type === 'init' && msg.session) {
|
||
remoteSession.value = msg.session as BrowserSessionData
|
||
return
|
||
}
|
||
if (msg.error) {
|
||
if (msg.error === 'session_not_found') {
|
||
handleRemoteSessionFailure(404, msg.error)
|
||
return
|
||
}
|
||
handleRemoteSessionFailure(undefined, String(msg.error))
|
||
}
|
||
} catch {
|
||
// ignore malformed JSON
|
||
}
|
||
}
|
||
|
||
socket.onerror = () => {
|
||
// onerror is always followed by onclose — handle everything in onclose
|
||
}
|
||
|
||
socket.onclose = (evt) => {
|
||
if (ws !== socket) return // already replaced
|
||
ws = null
|
||
// Don't reconnect if we intentionally closed or page is inactive
|
||
if (!props.active || !remoteSession.value) return
|
||
// Abnormal close: try to reconnect with exponential backoff
|
||
if (wsReconnectAttempts < WS_MAX_RECONNECT) {
|
||
const delay = WS_RECONNECT_BASE_MS * 2 ** wsReconnectAttempts
|
||
wsReconnectAttempts++
|
||
wsReconnectTimer = window.setTimeout(connectRemoteWs, delay)
|
||
} else {
|
||
handleRemoteSessionFailure(502, 'WebSocket 连接失败,已超过最大重连次数')
|
||
}
|
||
}
|
||
}
|
||
|
||
function stopRemoteWs() {
|
||
if (wsReconnectTimer !== undefined) {
|
||
window.clearTimeout(wsReconnectTimer)
|
||
wsReconnectTimer = undefined
|
||
}
|
||
if (ws) {
|
||
// Prevent onclose from triggering reconnect
|
||
const old = ws
|
||
ws = null
|
||
old.close()
|
||
}
|
||
}
|
||
|
||
/** Send an event to the remote browser over WebSocket (falls back to HTTP if WS not ready). */
|
||
async function sendRemoteEvent(payload: BrowserEventPayload) {
|
||
if (!props.active || isStartingRemoteBrowser.value) return
|
||
if (!remoteSession.value) return
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(payload))
|
||
return
|
||
}
|
||
// Fallback: HTTP POST (e.g. during reconnect window)
|
||
try {
|
||
const res = await browserSessionsApi.event(remoteSession.value.id, payload)
|
||
remoteSession.value = res.data
|
||
} catch (e: any) {
|
||
await handleRemoteSessionFailure(e.response?.status, e.response?.data?.detail || '远程浏览器会话已失效')
|
||
}
|
||
}
|
||
|
||
function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
|
||
sendRemoteEvent({ type })
|
||
}
|
||
|
||
function remoteViewport() {
|
||
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
||
return {
|
||
width: Math.round(rect?.width || window.innerWidth),
|
||
height: Math.round(rect?.height || window.innerHeight - 44),
|
||
}
|
||
}
|
||
|
||
function eventPoint(event: MouseEvent | PointerEvent, options: { clamp?: boolean } = {}) {
|
||
const image = event.currentTarget instanceof HTMLImageElement
|
||
? event.currentTarget
|
||
: remoteFrameRef.value?.querySelector<HTMLImageElement>('.remote-screen')
|
||
const rect = image?.getBoundingClientRect()
|
||
if (!image || !rect || !image.naturalWidth || !image.naturalHeight) return null
|
||
if (!options.clamp && (
|
||
event.clientX < rect.left ||
|
||
event.clientX > rect.right ||
|
||
event.clientY < rect.top ||
|
||
event.clientY > rect.bottom
|
||
)) {
|
||
return null
|
||
}
|
||
const clientX = options.clamp ? Math.max(rect.left, Math.min(rect.right, event.clientX)) : event.clientX
|
||
const clientY = options.clamp ? Math.max(rect.top, Math.min(rect.bottom, event.clientY)) : event.clientY
|
||
return {
|
||
x: Math.max(0, Math.min(image.naturalWidth, ((clientX - rect.left) / rect.width) * image.naturalWidth)),
|
||
y: Math.max(0, Math.min(image.naturalHeight, ((clientY - rect.top) / rect.height) * image.naturalHeight)),
|
||
}
|
||
}
|
||
|
||
function showRemoteCaret(event: MouseEvent | PointerEvent) {
|
||
focusRemoteFrame()
|
||
const point = eventPoint(event)
|
||
if (!point) return
|
||
// Show caret overlay at click position (proportional to displayed image)
|
||
const img = event.currentTarget instanceof HTMLImageElement
|
||
? event.currentTarget
|
||
: remoteFrameRef.value?.querySelector<HTMLImageElement>('.remote-screen')
|
||
const imgRect = img?.getBoundingClientRect()
|
||
const frameRect = remoteFrameRef.value?.getBoundingClientRect()
|
||
if (imgRect && frameRect && img) {
|
||
const scale = imgRect.height / (img.naturalHeight || imgRect.height)
|
||
const caretH = Math.max(14, Math.round(20 * scale))
|
||
remoteCaret.value = {
|
||
x: event.clientX - frameRect.left,
|
||
y: event.clientY - frameRect.top - caretH / 2,
|
||
h: caretH,
|
||
}
|
||
if (caretHideTimer) window.clearTimeout(caretHideTimer)
|
||
caretHideTimer = window.setTimeout(() => { remoteCaret.value = null }, 8000)
|
||
}
|
||
}
|
||
|
||
function onRemoteDblClick(event: MouseEvent) {
|
||
const point = eventPoint(event)
|
||
if (!point) return
|
||
sendRemoteEvent({ type: 'dblclick', ...point, button: mouseButton(event) })
|
||
}
|
||
|
||
function onRemotePointerDown(event: PointerEvent) {
|
||
if (event.pointerType === 'mouse' && event.button > 2) return
|
||
focusRemoteFrame()
|
||
showRemoteCaret(event)
|
||
const point = eventPoint(event)
|
||
if (!point) return
|
||
const button = mouseButton(event)
|
||
remotePointer.value = {
|
||
id: event.pointerId,
|
||
button,
|
||
dragging: true,
|
||
lastMoveAt: performance.now(),
|
||
}
|
||
remoteFrameRef.value?.setPointerCapture?.(event.pointerId)
|
||
sendRemoteEvent({ type: 'mousedown', ...point, button })
|
||
}
|
||
|
||
function onRemotePointerMove(event: PointerEvent) {
|
||
const activePointer = remotePointer.value
|
||
const dragging = Boolean(activePointer?.dragging && activePointer.id === event.pointerId)
|
||
const now = performance.now()
|
||
const throttleMs = dragging ? REMOTE_DRAG_MOVE_INTERVAL_MS : REMOTE_HOVER_MOVE_INTERVAL_MS
|
||
if (dragging && activePointer && now - activePointer.lastMoveAt < throttleMs) return
|
||
if (!dragging && mouseMoveTimer) return
|
||
if (dragging && activePointer) {
|
||
activePointer.lastMoveAt = now
|
||
} else {
|
||
mouseMoveTimer = window.setTimeout(() => { mouseMoveTimer = undefined }, throttleMs)
|
||
}
|
||
const point = eventPoint(event, { clamp: dragging })
|
||
if (!point) return
|
||
sendRemoteEvent({ type: 'mousemove', ...point })
|
||
}
|
||
|
||
function onRemotePointerUp(event: PointerEvent) {
|
||
const activePointer = remotePointer.value
|
||
if (!activePointer || activePointer.id !== event.pointerId) return
|
||
const point = eventPoint(event, { clamp: true })
|
||
remotePointer.value = null
|
||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||
if (!point) return
|
||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||
}
|
||
|
||
function onRemotePointerCancel(event: PointerEvent) {
|
||
const activePointer = remotePointer.value
|
||
if (!activePointer || activePointer.id !== event.pointerId) return
|
||
const point = eventPoint(event, { clamp: true })
|
||
remotePointer.value = null
|
||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||
if (!point) return
|
||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||
}
|
||
|
||
function onRemoteWheel(event: WheelEvent) {
|
||
const point = eventPoint(event)
|
||
if (!point) return
|
||
sendRemoteEvent({ type: 'scroll', delta_x: event.deltaX, delta_y: event.deltaY, ...point })
|
||
}
|
||
|
||
function onRemoteKeydown(event: KeyboardEvent) {
|
||
// Let Ctrl+V / Meta+V propagate so the browser fires the 'paste' event
|
||
// which we handle in onRemotePaste with the actual clipboard text.
|
||
const isPaste = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v'
|
||
if (isPaste) return
|
||
|
||
// Prevent browser from handling other keys (scrolling, shortcuts, etc.)
|
||
event.preventDefault()
|
||
|
||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||
sendRemoteEvent({ type: 'type', text: event.key })
|
||
return
|
||
}
|
||
const key = keyName(event)
|
||
if (key) sendRemoteEvent({ type: 'key', key })
|
||
}
|
||
|
||
/** Paste handler: sends local clipboard text as a 'type' event to the remote browser. */
|
||
function onRemotePaste(event: ClipboardEvent) {
|
||
const text = event.clipboardData?.getData('text/plain') || ''
|
||
if (text) sendRemoteEvent({ type: 'type', text })
|
||
}
|
||
|
||
function keyName(event: KeyboardEvent) {
|
||
const parts: string[] = []
|
||
if (event.ctrlKey) parts.push('Control')
|
||
if (event.altKey) parts.push('Alt')
|
||
if (event.metaKey) parts.push('Meta')
|
||
if (event.shiftKey && event.key.length !== 1) parts.push('Shift')
|
||
const key = event.key === ' ' ? 'Space' : event.key
|
||
if (!['Control', 'Alt', 'Meta', 'Shift'].includes(key)) parts.push(key)
|
||
return parts.join('+')
|
||
}
|
||
|
||
function mouseButton(event: MouseEvent | PointerEvent): 'left' | 'right' | 'middle' {
|
||
if (event.button === 1) return 'middle'
|
||
if (event.button === 2) return 'right'
|
||
return 'left'
|
||
}
|
||
|
||
function focusRemoteFrame() {
|
||
remoteFrameRef.value?.focus()
|
||
}
|
||
|
||
async function closeRemoteSession() {
|
||
if (!remoteSession.value) return
|
||
const id = remoteSession.value.id
|
||
remoteSession.value = null
|
||
setRemoteScreenshotUrl('')
|
||
await browserSessionsApi.close(id).catch(() => undefined)
|
||
}
|
||
|
||
function setRemoteScreenshotUrl(url: string) {
|
||
if (screenshotObjectUrl) {
|
||
URL.revokeObjectURL(screenshotObjectUrl)
|
||
screenshotObjectUrl = ''
|
||
}
|
||
remoteScreenshotUrl.value = url
|
||
if (url.startsWith('blob:')) screenshotObjectUrl = url
|
||
}
|
||
|
||
function retryRemoteBrowser() {
|
||
startRemoteBrowser({ reconnect: true })
|
||
}
|
||
|
||
function clearRemoteError() {
|
||
remoteErrorState.value = null
|
||
}
|
||
|
||
function normalizeRemoteErrorDetail(message?: string) {
|
||
return (message || '')
|
||
.trim()
|
||
.replace(/^Browser error:\s*/i, '')
|
||
.replace(/^session error:\s*/i, '')
|
||
.replace(/\s+/g, ' ')
|
||
}
|
||
|
||
function mapRemoteBrowserError(status?: number, message?: string): RemoteBrowserErrorState {
|
||
const detail = normalizeRemoteErrorDetail(message)
|
||
const lowerDetail = detail.toLowerCase()
|
||
const actionLabel = '重新创建会话'
|
||
|
||
if (
|
||
status === 503
|
||
|| lowerDetail.includes('browserdependencyerror')
|
||
|| lowerDetail.includes('playwright is not installed')
|
||
|| lowerDetail.includes('unable to start playwright')
|
||
) {
|
||
return {
|
||
title: '远程浏览器依赖未安装',
|
||
description: '当前服务端缺少 Playwright 或浏览器运行时,暂时无法创建远程浏览器会话。',
|
||
hint: '请先在服务端安装远程浏览器依赖,再返回这里重新创建会话。',
|
||
actionLabel,
|
||
technicalDetail: detail,
|
||
}
|
||
}
|
||
|
||
if (
|
||
status === 404
|
||
|| lowerDetail.includes('session_not_found')
|
||
|| lowerDetail.includes('browser session not found')
|
||
|| lowerDetail.includes('会话已失效')
|
||
) {
|
||
return {
|
||
title: '远程浏览器会话已失效',
|
||
description: '当前远程浏览器会话已经不存在,可能是服务重启、会话过期或会话已被清理。',
|
||
hint: '点击下方按钮可重新创建会话,并继续在当前页面中查看远程内容。',
|
||
actionLabel,
|
||
technicalDetail: detail,
|
||
}
|
||
}
|
||
|
||
if (
|
||
status === 409
|
||
|| lowerDetail.includes('browser page is closed')
|
||
|| lowerDetail.includes('page is closed')
|
||
) {
|
||
return {
|
||
title: '远程页面已关闭',
|
||
description: '当前远程浏览器页面已经关闭,现有会话不能继续使用。',
|
||
hint: '请重新创建会话,系统会重新打开并连接到目标页面。',
|
||
actionLabel,
|
||
technicalDetail: detail,
|
||
}
|
||
}
|
||
|
||
if (status === 502) {
|
||
return {
|
||
title: '远程浏览器连接异常',
|
||
description: '服务端远程浏览器在启动或通信过程中发生异常,当前页面未能成功连接。',
|
||
hint: '可以先重试创建会话;如果持续失败,请检查服务端浏览器环境和目标站点可访问性。',
|
||
actionLabel,
|
||
technicalDetail: detail,
|
||
}
|
||
}
|
||
|
||
return {
|
||
title: '远程浏览器暂时不可用',
|
||
description: '当前页面模块未能建立远程浏览器连接,其它后台区域仍可继续使用。',
|
||
hint: '请尝试重新创建会话;如果问题持续存在,再检查服务端日志中的浏览器错误。',
|
||
actionLabel,
|
||
technicalDetail: detail,
|
||
}
|
||
}
|
||
|
||
function setRemoteError(status: number | undefined, message?: string) {
|
||
iframeLoading.value = false
|
||
remoteSession.value = null
|
||
setRemoteScreenshotUrl('')
|
||
remoteCaret.value = null
|
||
remoteErrorState.value = mapRemoteBrowserError(status, message)
|
||
}
|
||
|
||
async function handleRemoteSessionFailure(status: number | undefined, message: string) {
|
||
stopRemoteWs()
|
||
await closeRemoteSession()
|
||
setRemoteError(status, message || '远程浏览器会话已失效')
|
||
}
|
||
|
||
watch(() => route.params.id, (id) => {
|
||
if (!props.pageId && id) {
|
||
stopRemoteWs()
|
||
closeRemoteSession()
|
||
loadPage(Number(id))
|
||
}
|
||
}, { immediate: false })
|
||
|
||
watch(() => props.pageId, (id, oldId) => {
|
||
if (id && id !== oldId) {
|
||
stopRemoteWs()
|
||
closeRemoteSession()
|
||
loadPage(id)
|
||
}
|
||
})
|
||
|
||
watch(() => props.active, (active) => {
|
||
if (!isRemoteBrowser.value) return
|
||
if (active) {
|
||
if (remoteSession.value && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||
connectRemoteWs()
|
||
}
|
||
nextTick(() => remoteFrameRef.value?.focus())
|
||
} else {
|
||
stopRemoteWs()
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
loadPage(effectivePageId.value)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopRemoteWs()
|
||
closeRemoteSession()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.viewer-wrap {
|
||
/* Expand to full viewport minus topbar, ignoring parent padding */
|
||
position: fixed;
|
||
inset: 0;
|
||
top: var(--topbar-height);
|
||
left: var(--sidebar-width);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--bg-base);
|
||
z-index: 10;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.viewer-wrap.embedded {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: auto;
|
||
}
|
||
|
||
.viewer-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.viewer-bar {
|
||
height: 34px;
|
||
background: var(--bg-surface);
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 0 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.viewer-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.viewer-url {
|
||
flex: 1;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
background: var(--bg-elevated);
|
||
border-radius: 6px;
|
||
padding: 3px 8px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
}
|
||
|
||
.viewer-actions { display: flex; gap: 2px; }
|
||
|
||
.viewer-actions :deep(.el-button) {
|
||
min-height: 1.75rem;
|
||
padding: 0 0.45rem;
|
||
}
|
||
|
||
.viewer-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.viewer-content-iframe {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.viewer-content-remote {
|
||
padding: 0.75rem;
|
||
background:
|
||
radial-gradient(circle at top, rgba(217, 139, 66, 0.08), transparent 30%),
|
||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), rgba(10, 8, 7, 0.04));
|
||
}
|
||
|
||
.viewer-stage {
|
||
position: relative;
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid rgba(140, 119, 98, 0.28);
|
||
border-radius: 1.25rem;
|
||
background:
|
||
linear-gradient(180deg, rgba(31, 25, 21, 0.96), rgba(18, 14, 12, 0.98)),
|
||
var(--bg-surface);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 244, 232, 0.03);
|
||
}
|
||
|
||
.viewer-stage.viewer-stage-error {
|
||
background:
|
||
linear-gradient(180deg, rgba(47, 38, 31, 0.96), rgba(24, 19, 16, 0.98)),
|
||
var(--bg-surface);
|
||
}
|
||
|
||
.page-iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
border: none;
|
||
background: #fff;
|
||
}
|
||
|
||
.remote-frame {
|
||
flex: 1;
|
||
width: 100%;
|
||
min-height: 0;
|
||
background: #f7f8fa;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
outline: none;
|
||
overflow: hidden;
|
||
touch-action: none;
|
||
position: relative;
|
||
padding: 0.6rem;
|
||
}
|
||
|
||
.remote-screen {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
user-select: none;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.remote-caret {
|
||
position: absolute;
|
||
width: 2px;
|
||
background: #1a73e8;
|
||
border-radius: 1px;
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
transform: translateX(-1px);
|
||
animation: caret-blink 1.1s step-end infinite;
|
||
box-shadow: 0 0 0 1px rgba(255,255,255,0.6);
|
||
}
|
||
@keyframes caret-blink {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0; }
|
||
}
|
||
|
||
.stage-overlay,
|
||
.iframe-loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
background: rgba(18, 14, 12, 0.68);
|
||
backdrop-filter: blur(4px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.iframe-loading {
|
||
background: var(--bg-base);
|
||
backdrop-filter: none;
|
||
}
|
||
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
color: var(--color-primary);
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.error-icon { color: #f59e0b; }
|
||
|
||
.remote-error-shell {
|
||
height: 100%;
|
||
padding: 1rem;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.remote-error-card {
|
||
width: min(100%, 34rem);
|
||
padding: 1.5rem;
|
||
display: grid;
|
||
gap: 1.2rem;
|
||
border-radius: 1.25rem;
|
||
border: 1px solid rgba(239, 175, 99, 0.18);
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 244, 232, 0.08), rgba(255, 244, 232, 0.03)),
|
||
rgba(28, 22, 18, 0.92);
|
||
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.remote-error-copy {
|
||
display: grid;
|
||
gap: 0.7rem;
|
||
}
|
||
|
||
.remote-error-kicker {
|
||
color: var(--text-soft);
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.error-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.error-description,
|
||
.error-hint,
|
||
.error-detail {
|
||
margin: 0;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.error-description {
|
||
font-size: 0.95rem;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.error-hint {
|
||
font-size: 0.88rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.error-detail {
|
||
font-size: 0.82rem;
|
||
color: var(--text-soft);
|
||
}
|
||
|
||
.remote-error-actions {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.viewer-wrap {
|
||
left: 0;
|
||
}
|
||
|
||
.viewer-bar {
|
||
height: auto;
|
||
min-height: 52px;
|
||
flex-wrap: wrap;
|
||
padding: 8px;
|
||
}
|
||
|
||
.viewer-title {
|
||
max-width: calc(100% - 92px);
|
||
}
|
||
|
||
.viewer-url {
|
||
order: 4;
|
||
flex-basis: 100%;
|
||
}
|
||
|
||
.viewer-content-remote {
|
||
padding: 0.6rem;
|
||
}
|
||
|
||
.remote-frame,
|
||
.remote-error-shell {
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
.remote-error-card {
|
||
padding: 1.15rem;
|
||
}
|
||
|
||
.remote-error-actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.remote-error-actions :deep(.el-button) {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|