Files
SmartUp/frontend/src/views/PageViewer.vue
T

1426 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="copyRemoteSelection">
<el-icon><DocumentCopy /></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 v-if="canRefreshAuth" content="一键刷新上游凭证">
<el-button size="small" text type="warning" :loading="refreshingAuth" @click="refreshAuth">
<el-icon><Key /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="canAutofillLogin" content="填入账号密码">
<el-button size="small" text type="primary" :loading="autofilling" @click="triggerAutofillLogin">
<el-icon><EditPen /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="重建浏览器(保留登录态)">
<el-button size="small" text @click="reconnectBrowser" :disabled="isStartingRemoteBrowser">
<el-icon><Connection /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="清除登录态(删除 profile,需重新登录)">
<el-button size="small" text type="danger" :loading="clearingProfile" @click="clearProfile">
<el-icon><Delete /></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 }">
<!-- Multi-tab bar -->
<div v-if="!showRemoteError && remoteSession?.tabs?.length" class="remote-tabs">
<div
v-for="tab in remoteSession.tabs"
:key="tab.id"
class="remote-tab"
:class="{ active: tab.id === remoteSession.active_tab_id }"
@click="switchRemoteTab(tab.id)"
>
<span class="tab-title" :title="tab.url">{{ tab.title || 'Loading...' }}</span>
<el-icon
v-if="remoteSession.tabs.length > 1"
class="tab-close"
@click.stop="closeRemoteTab(tab.id)"
>
<Close />
</el-icon>
</div>
</div>
<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="onRemoteImageLoad"
@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, ElMessageBox } from 'element-plus'
import {
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Close,
} 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)
const lastSyncedClipboard = ref('')
const startRemoteBrowserPromises = new Map<number, Promise<void>>()
let closingRemoteSessionPromise: Promise<void> | null = null
let wsFirstFrameTimer: number | undefined
let screenshotObjectUrl = ''
let previousScreenshotObjectUrl = ''
let pendingScreenshotBlob: Blob | null = null
let screenshotFrameRequest: number | undefined
let mouseMoveTimer: number | undefined
let wheelTimer: number | undefined
let pendingWheel: { deltaX: number; deltaY: number; x: number; y: number } | null = null
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 WS_BACKPRESSURE_BYTES = 256 * 1024
const REMOTE_DRAG_MOVE_INTERVAL_MS = 32
const REMOTE_HOVER_MOVE_INTERVAL_MS = 120
const REMOTE_WHEEL_INTERVAL_MS = 45
const HIGH_FREQUENCY_EVENTS = new Set(['mousemove', 'scroll'])
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 canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
const refreshingAuth = ref(false)
const clearingProfile = ref(false)
const autofilling = ref(false)
const canAutofillLogin = computed(() =>
isRemoteBrowser.value && remoteSession.value &&
page.value?.login_autofill_enabled && page.value?.login_username &&
page.value?.login_password_configured
)
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()
// Guard: page may have changed during async fetch
if (effectivePageId.value !== id) return
page.value = res.data.find(p => p.id === id) || null
if (!page.value) {
ElMessage.error('页面不存在')
if (effectivePageId.value === id) 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 } = {}) {
const pid = effectivePageId.value
const existing = startRemoteBrowserPromises.get(pid)
if (existing) return existing
const promise = doStartRemoteBrowser(options).finally(() => {
if (startRemoteBrowserPromises.get(pid) === promise) {
startRemoteBrowserPromises.delete(pid)
}
})
startRemoteBrowserPromises.set(pid, promise)
return promise
}
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()
// Wait for any in-flight close to finish (avoids profile contention)
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
// Capture the intended page id BEFORE the async request
const requestedPageId = page.value.id
try {
const viewport = remoteViewport()
const res = await browserSessionsApi.create({
custom_page_id: requestedPageId,
width: viewport.width,
height: viewport.height,
})
// Guard: page may have changed while we were creating.
// Verify both the current route AND the backend-returned page match.
if (effectivePageId.value !== requestedPageId || res.data.custom_page_id !== requestedPageId) {
await browserSessionsApi.close(res.data.id).catch(() => {})
return
}
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
// First-frame watchdog: if no screenshot arrives within 10s, show error
wsFirstFrameTimer = window.setTimeout(() => {
if (ws === socket) {
handleRemoteSessionFailure(502, '远程浏览器无响应(首帧超时)')
}
}, 10000)
}
socket.onmessage = (evt) => {
if (evt.data instanceof Blob) {
// Clear first-frame watchdog when first screenshot arrives
if (wsFirstFrameTimer !== undefined) {
window.clearTimeout(wsFirstFrameTimer)
wsFirstFrameTimer = undefined
}
queueRemoteScreenshot(evt.data)
return
}
// Text frame = JSON control message
try {
const msg = JSON.parse(evt.data as string)
if ((msg.type === 'init' || msg.type === 'state') && 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 (wsFirstFrameTimer !== undefined) {
window.clearTimeout(wsFirstFrameTimer)
wsFirstFrameTimer = undefined
}
if (wsReconnectTimer !== undefined) {
window.clearTimeout(wsReconnectTimer)
wsReconnectTimer = undefined
}
if (wheelTimer !== undefined) {
window.clearTimeout(wheelTimer)
wheelTimer = undefined
}
pendingWheel = null
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
const highFrequency = HIGH_FREQUENCY_EVENTS.has(payload.type)
if (ws && ws.readyState === WebSocket.OPEN) {
if (highFrequency && ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return
ws.send(JSON.stringify(payload))
return
}
if (highFrequency) return
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 })
}
async function switchRemoteTab(tabId: string) {
if (!remoteSession.value || remoteSession.value.active_tab_id === tabId) return
try {
const res = await browserSessionsApi.activateTab(remoteSession.value.id, tabId)
remoteSession.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '切换标签页失败')
}
}
async function closeRemoteTab(tabId: string) {
if (!remoteSession.value) return
try {
const res = await browserSessionsApi.closeTab(remoteSession.value.id, tabId)
remoteSession.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '关闭标签页失败')
}
}
async function copyRemoteSelection() {
if (!remoteSession.value) return
try {
const res = await browserSessionsApi.selection(remoteSession.value.id)
const text = res.data.text.trim()
if (!text) {
ElMessage.warning('远程页面没有选中文本')
return
}
await navigator.clipboard.writeText(text)
ElMessage.success('已复制远程选中文本')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '复制失败')
}
}
async function refreshAuth() {
if (!page.value) return
refreshingAuth.value = true
try {
const res = await customPagesApi.refreshAuth(page.value.id)
if (res.data.success) {
ElMessage.success(res.data.message || '凭证已刷新')
// 宽松验证模式:凭证已写入但 API 调用失败时后端会返回 warning
if (res.data.warning) {
ElMessage.warning(res.data.warning)
}
} else {
ElMessage.warning(res.data.message || '刷新失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '刷新凭证失败')
} finally {
refreshingAuth.value = false
}
}
async function triggerAutofillLogin() {
const sid = remoteSession.value?.id
const cpid = page.value?.id
if (!sid) return
autofilling.value = true
try {
const res = await browserSessionsApi.autofillLogin(sid)
// Guard: page or session may have changed during async request
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
if (res.data.success) {
ElMessage.success(res.data.message)
} else {
ElMessage.warning(res.data.message)
}
} catch (e: any) {
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
ElMessage.error(e.response?.data?.detail || '填入失败')
} finally {
autofilling.value = false
}
}
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 })
syncClipboard(remoteSession.value?.id)
}
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 })
syncClipboard(remoteSession.value?.id)
}
function onRemoteWheel(event: WheelEvent) {
const point = eventPoint(event)
if (!point) return
if (!pendingWheel) {
pendingWheel = { deltaX: 0, deltaY: 0, ...point }
}
pendingWheel.deltaX += event.deltaX
pendingWheel.deltaY += event.deltaY
pendingWheel.x = point.x
pendingWheel.y = point.y
if (wheelTimer !== undefined) return
wheelTimer = window.setTimeout(flushRemoteWheel, REMOTE_WHEEL_INTERVAL_MS)
}
function flushRemoteWheel() {
wheelTimer = undefined
const wheel = pendingWheel
pendingWheel = null
if (!wheel) return
sendRemoteEvent({ type: 'scroll', delta_x: wheel.deltaX, delta_y: wheel.deltaY, x: wheel.x, y: wheel.y })
}
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
const isCopy = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c'
if (isCopy) {
event.preventDefault()
copyRemoteSelection()
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()
}
const CLIPBOARD_SYNC_DELAY_MS = 800
async function syncClipboard(capturedSessionId?: string) {
// Must be called with a captured sessionId; if none, capture now
const sid = capturedSessionId || remoteSession.value?.id
const cpid = page.value?.id
if (!sid) return
await new Promise((r) => setTimeout(r, CLIPBOARD_SYNC_DELAY_MS))
// Guard: session or page may have changed during delay
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
try {
const res = await browserSessionsApi.clipboard(sid)
const data = res.data
if (!data.text) {
if (data.error && data.error !== '远程剪贴板为空') {
console.debug('clipboard sync:', data.error)
}
return
}
if (data.text === lastSyncedClipboard.value) return
const text = data.text
lastSyncedClipboard.value = text
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已同步到本机剪贴板')
} catch {
// Browser blocked clipboard write — try execCommand fallback
let copied = false
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
copied = document.execCommand('copy')
} catch {}
document.body.removeChild(textarea)
if (copied) {
ElMessage.success('已同步到本机剪贴板')
} else {
// Show dialog with selectable text as last resort
ElMessageBox.alert(text, '远程剪贴板内容', {
confirmButtonText: '已复制',
type: 'info',
dangerouslyUseHTMLString: false,
message: `请手动复制下方内容:\n\n${text}`,
})
}
}
} catch {
// Clipboard read failed — silently ignore (likely empty or permission denied)
}
}
async function clearProfile() {
if (!page.value || !remoteSession.value) return
try {
clearingProfile.value = true
await ElMessageBox.confirm('清除登录态后需要重新登录,确定继续?', '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
clearingProfile.value = false
return
}
stopRemoteWs()
try {
await browserSessionsApi.clearProfile(page.value.id)
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '清除登录态失败')
clearingProfile.value = false
return
}
remoteSession.value = null
setRemoteScreenshotUrl('')
clearingProfile.value = false
startRemoteBrowser()
}
async function closeRemoteSession() {
if (!remoteSession.value) {
// If already null but a close is in flight, wait for it
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
return
}
const id = remoteSession.value.id
remoteSession.value = null
setRemoteScreenshotUrl('')
remoteErrorState.value = null
iframeLoading.value = true
const promise = browserSessionsApi.close(id).then<void | undefined>(() => undefined).catch(() => undefined)
closingRemoteSessionPromise = promise
try {
await promise
} finally {
if (closingRemoteSessionPromise === promise) {
closingRemoteSessionPromise = null
}
}
}
function queueRemoteScreenshot(blob: Blob) {
pendingScreenshotBlob = blob
if (screenshotFrameRequest !== undefined) return
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
function renderPendingScreenshot() {
screenshotFrameRequest = undefined
const blob = pendingScreenshotBlob
pendingScreenshotBlob = null
if (!blob) return
const nextUrl = URL.createObjectURL(blob)
if (previousScreenshotObjectUrl) URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = screenshotObjectUrl
screenshotObjectUrl = nextUrl
remoteScreenshotUrl.value = nextUrl
}
function onRemoteImageLoad() {
iframeLoading.value = false
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (pendingScreenshotBlob && screenshotFrameRequest === undefined) {
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
}
function setRemoteScreenshotUrl(url: string) {
if (screenshotFrameRequest !== undefined) {
window.cancelAnimationFrame(screenshotFrameRequest)
screenshotFrameRequest = undefined
}
pendingScreenshotBlob = null
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (screenshotObjectUrl) {
URL.revokeObjectURL(screenshotObjectUrl)
screenshotObjectUrl = ''
}
remoteScreenshotUrl.value = url
if (url.startsWith('blob:')) screenshotObjectUrl = url
}
function retryRemoteBrowser() {
startRemoteBrowser({ reconnect: true })
}
/** Close current session and start fresh (login preserved via profile on disk). */
async function reconnectBrowser() {
stopRemoteWs()
await closeRemoteSession()
startRemoteBrowser()
}
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 (lowerDetail.includes('首帧超时')) {
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, async (id) => {
if (!props.pageId && id) {
stopRemoteWs()
await closeRemoteSession()
loadPage(Number(id))
}
}, { immediate: false })
watch(() => props.pageId, async (id, oldId) => {
if (id && id !== oldId) {
stopRemoteWs()
await closeRemoteSession()
loadPage(id)
}
})
watch(() => props.active, async (active) => {
if (!isRemoteBrowser.value) return
if (active) {
if (remoteSession.value) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectRemoteWs()
}
} else {
// Session was closed while inactive — restart from profile
await startRemoteBrowser()
}
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);
}
.remote-tabs {
display: flex;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(140, 119, 98, 0.15);
padding: 4px 6px 0;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.remote-tabs::-webkit-scrollbar { display: none; }
.remote-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px 8px 0 0;
color: var(--text-soft);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
max-width: 180px;
transition: all 0.2s;
border: 1px solid transparent;
border-bottom: none;
}
.remote-tab:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.remote-tab.active {
background: var(--bg-surface);
color: var(--text-primary);
font-weight: 600;
border-color: rgba(140, 119, 98, 0.15);
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
font-size: 12px;
padding: 2px;
border-radius: 4px;
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #f56c6c;
}
.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>