Files
SmartUp/frontend/src/views/PageViewer.vue
T
liumangmang 7adc7c00ab Add remote browser pages and website sync
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>
2026-05-15 15:43:58 +08:00

1010 lines
29 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="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>