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:
liumangmang
2026-05-20 09:44:20 +08:00
parent 4c71148ff9
commit 6cc797f915
16 changed files with 773 additions and 52 deletions
+218 -19
View File
@@ -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 {