feat: remote browser login persistence + balance display + UI consistency
- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
This commit is contained in:
@@ -33,6 +33,21 @@
|
||||
<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>
|
||||
@@ -134,9 +149,9 @@
|
||||
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
|
||||
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,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -172,7 +187,10 @@ const remoteScreenshotUrl = ref('')
|
||||
const isStartingRemoteBrowser = ref(false)
|
||||
const isReconnectingRemoteBrowser = ref(false)
|
||||
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
|
||||
let startRemoteBrowserPromise: Promise<void> | 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
|
||||
@@ -216,6 +234,13 @@ 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)
|
||||
@@ -236,10 +261,12 @@ async function loadPage(id: number) {
|
||||
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('页面不存在')
|
||||
router.push('/custom-pages')
|
||||
if (effectivePageId.value === id) router.push('/custom-pages')
|
||||
return
|
||||
}
|
||||
if (isRemoteBrowser.value) {
|
||||
@@ -278,11 +305,16 @@ function reload() {
|
||||
}
|
||||
|
||||
async function startRemoteBrowser(options: { reconnect?: boolean } = {}) {
|
||||
if (startRemoteBrowserPromise) return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromise = doStartRemoteBrowser(options).finally(() => {
|
||||
startRemoteBrowserPromise = null
|
||||
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)
|
||||
}
|
||||
})
|
||||
return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromises.set(pid, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
@@ -296,14 +328,23 @@ async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
clearRemoteError()
|
||||
await nextTick()
|
||||
stopRemoteWs()
|
||||
await closeRemoteSession()
|
||||
// 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: page.value.id,
|
||||
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) {
|
||||
@@ -334,10 +375,21 @@ function connectRemoteWs() {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -381,6 +433,10 @@ function connectRemoteWs() {
|
||||
}
|
||||
|
||||
function stopRemoteWs() {
|
||||
if (wsFirstFrameTimer !== undefined) {
|
||||
window.clearTimeout(wsFirstFrameTimer)
|
||||
wsFirstFrameTimer = undefined
|
||||
}
|
||||
if (wsReconnectTimer !== undefined) {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = undefined
|
||||
@@ -454,6 +510,28 @@ async function refreshAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -555,6 +633,7 @@ function onRemotePointerUp(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemotePointerCancel(event: PointerEvent) {
|
||||
@@ -565,6 +644,7 @@ function onRemotePointerCancel(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemoteWheel(event: WheelEvent) {
|
||||
@@ -639,12 +719,109 @@ 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) return
|
||||
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('')
|
||||
await browserSessionsApi.close(id).catch(() => undefined)
|
||||
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) {
|
||||
@@ -698,6 +875,13 @@ 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
|
||||
}
|
||||
@@ -759,6 +943,16 @@ function mapRemoteBrowserError(status?: number, message?: string): RemoteBrowser
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerDetail.includes('首帧超时')) {
|
||||
return {
|
||||
title: '远程浏览器无响应',
|
||||
description: '浏览器已启动但长时间未返回画面,可能是页面卡死、弹窗遮挡或加载过慢。',
|
||||
hint: '点击按钮可重建浏览器并刷新页面连接。',
|
||||
actionLabel: '重新创建会话',
|
||||
technicalDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 502) {
|
||||
return {
|
||||
title: '远程浏览器连接异常',
|
||||
@@ -792,27 +986,32 @@ async function handleRemoteSessionFailure(status: number | undefined, message: s
|
||||
setRemoteError(status, message || '远程浏览器会话已失效')
|
||||
}
|
||||
|
||||
watch(() => route.params.id, (id) => {
|
||||
watch(() => route.params.id, async (id) => {
|
||||
if (!props.pageId && id) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(Number(id))
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
watch(() => props.pageId, (id, oldId) => {
|
||||
watch(() => props.pageId, async (id, oldId) => {
|
||||
if (id && id !== oldId) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(id)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
watch(() => props.active, async (active) => {
|
||||
if (!isRemoteBrowser.value) return
|
||||
if (active) {
|
||||
if (remoteSession.value && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
connectRemoteWs()
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user