Remove server remote browser support

This commit is contained in:
liumangmang
2026-06-02 19:25:20 +08:00
parent 3181a6f6cc
commit a42bcba483
22 changed files with 151 additions and 5029 deletions
+11 -173
View File
@@ -55,10 +55,10 @@
<div class="sidebar-section sidebar-section-pages">
<div class="sidebar-section-head">
<div>
<div class="sidebar-section-title">自定义页面</div>
<p class="sidebar-section-note">聚合外部控制台与嵌入页面</p>
<div class="sidebar-section-title">上游网址</div>
<p class="sidebar-section-note">外部控制台快捷入口</p>
</div>
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面" @click="closeMobileNav">
<router-link to="/custom-pages" class="nav-manage-link" title="管理上游网址" @click="closeMobileNav">
<el-icon><Setting /></el-icon>
</router-link>
</div>
@@ -69,7 +69,6 @@
v-for="page in customPages"
:key="page.id"
class="nav-item nav-item-custom"
:class="{ active: isCustomPageActive(page.id) }"
href="#"
@click.prevent="openCustomPage(page)"
>
@@ -82,14 +81,14 @@
</template>
<router-link v-else to="/custom-pages" class="add-page-link" @click="closeMobileNav">
<el-icon><Plus /></el-icon>
<span>添加页面</span>
<span>添加网址</span>
</router-link>
</div>
</div>
</aside>
<div class="main-wrap">
<header class="topbar" :class="{ compact: customPageTabs.length > 0 && isCustomPageRoute }">
<header class="topbar">
<div class="topbar-main">
<button type="button" class="mobile-menu" @click="mobileNavOpen = true" aria-label="打开导航">
<el-icon><Operation /></el-icon>
@@ -108,45 +107,18 @@
</div>
</header>
<main class="page-content" :class="{ 'has-custom-tabs': customPageTabs.length > 0 && isCustomPageRoute }">
<main class="page-content">
<router-view v-slot="{ Component }">
<component :is="Component" v-if="!isCustomPageRoute" />
<component :is="Component" />
</router-view>
<div v-show="isCustomPageRoute && customPageTabs.length > 0" class="custom-tabs-shell">
<div class="custom-tabs-bar">
<button
v-for="tab in customPageTabs"
:key="tab.id"
type="button"
class="custom-tab"
:class="{ active: tab.id === activeCustomPageId }"
@click="activateCustomPage(tab.id)"
>
<el-icon><component :is="iconMap[tab.icon] || LinkIcon" /></el-icon>
<span>{{ tab.name }}</span>
<el-icon class="custom-tab-close" @click.stop="closeCustomPageTab(tab.id)"><Close /></el-icon>
</button>
</div>
<div class="custom-tabs-content">
<PageViewer
v-for="tab in customPageTabs"
:key="tab.id"
:page-id="tab.id"
:active="isCustomPageRoute && tab.id === activeCustomPageId"
embedded
v-show="tab.id === activeCustomPageId"
/>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Link as LinkIcon,
@@ -174,10 +146,8 @@ import {
Operation,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
import PageViewer from '@/views/PageViewer.vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const mobileNavOpen = ref(false)
@@ -198,20 +168,11 @@ const iconMap: Record<string, any> = {
}
const customPages = ref<CustomPageData[]>([])
const customPageTabs = ref<CustomPageData[]>([])
const activeCustomPageId = ref<number | null>(null)
const isCustomPageRoute = computed(() => route.path.startsWith('/page/'))
async function loadCustomPages() {
try {
const res = await customPagesApi.list()
customPages.value = res.data.filter((page) => page.enabled)
customPageTabs.value = customPageTabs.value
.map((tab) => customPages.value.find((page) => page.id === tab.id))
.filter((page): page is CustomPageData => Boolean(page))
if (activeCustomPageId.value && !customPageTabs.value.some((tab) => tab.id === activeCustomPageId.value)) {
activeCustomPageId.value = customPageTabs.value[0]?.id ?? null
}
} catch {
// sidebar data is non-blocking
}
@@ -226,34 +187,8 @@ function closeMobileNav() {
}
function openCustomPage(page: CustomPageData) {
if (!customPageTabs.value.some((tab) => tab.id === page.id)) {
customPageTabs.value.push(page)
}
closeMobileNav()
activateCustomPage(page.id)
}
function activateCustomPage(id: number) {
activeCustomPageId.value = id
if (route.path !== `/page/${id}`) router.push(`/page/${id}`)
}
function closeCustomPageTab(id: number) {
const index = customPageTabs.value.findIndex((tab) => tab.id === id)
if (index === -1) return
customPageTabs.value.splice(index, 1)
if (activeCustomPageId.value !== id) return
const next = customPageTabs.value[index] || customPageTabs.value[index - 1]
if (next) {
activateCustomPage(next.id)
} else {
activeCustomPageId.value = null
router.push('/custom-pages')
}
}
function isCustomPageActive(id: number) {
return isCustomPageRoute.value && activeCustomPageId.value === id
window.open(page.url, '_blank', 'noopener,noreferrer')
}
function handleLogout() {
@@ -270,14 +205,6 @@ onUnmounted(() => {
window.removeEventListener('custom-pages-updated', onPagesUpdated)
})
watch([() => route.path, customPages], () => {
mobileNavOpen.value = false
if (!isCustomPageRoute.value) return
const id = Number(route.params.id)
if (!id || activeCustomPageId.value === id) return
const page = customPages.value.find((item) => item.id === id)
if (page) openCustomPage(page)
})
</script>
<style scoped>
@@ -340,8 +267,7 @@ watch([() => route.path, customPages], () => {
.sidebar-brand,
.sidebar-section,
.topbar,
.custom-tabs-bar {
.topbar {
border: 1px solid var(--border-color);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(14px);
@@ -548,12 +474,6 @@ watch([() => route.path, customPages], () => {
background: rgba(25, 19, 16, 0.7);
}
.topbar.compact {
min-height: 2.8rem;
padding: 0.4rem 0.75rem;
border-radius: calc(var(--radius-shell) - 0.35rem);
}
.topbar-main,
.topbar-right,
.topbar-status,
@@ -592,81 +512,6 @@ watch([() => route.path, customPages], () => {
padding: 0.2rem;
}
.page-content.has-custom-tabs {
overflow: hidden;
padding: 0;
}
.custom-tabs-shell {
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.45rem;
}
.custom-tabs-bar {
min-height: 2.4rem;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.45rem;
border-radius: calc(var(--radius-shell) - 0.45rem);
background: rgba(25, 19, 16, 0.7);
overflow-x: auto;
}
.custom-tab {
min-height: 1.9rem;
min-width: 6rem;
max-width: 12rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.65rem;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: rgba(255, 244, 232, 0.02);
color: var(--text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.custom-tab span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-tab.active {
background: linear-gradient(135deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
border-color: rgba(239, 175, 99, 0.18);
color: var(--text-primary);
}
.custom-tab-close {
width: 1rem;
height: 1rem;
padding: 0.1rem;
border-radius: 0.35rem;
}
.custom-tab-close:hover {
background: rgba(221, 126, 114, 0.14);
color: var(--color-danger);
}
.custom-tabs-content {
position: relative;
flex: 1;
min-height: 0;
padding: 0.45rem;
border-radius: var(--radius-shell);
overflow: hidden;
background: rgba(10, 8, 7, 0.16);
}
@media (min-width: 1024px) {
.layout-shell {
gap: var(--shell-padding);
@@ -723,12 +568,5 @@ watch([() => route.path, customPages], () => {
justify-content: center;
}
.custom-tabs-bar {
min-height: 3.7rem;
}
.custom-tab {
min-height: 2.9rem;
}
}
</style>
+96 -544
View File
@@ -1,124 +1,77 @@
<template>
<el-dialog
v-model="visible"
title="🔐 远程浏览器认证提取"
width="960px"
title="真实浏览器认证导入"
width="720px"
:close-on-click-modal="false"
:before-close="handleClose"
destroy-on-close
>
<div class="auth-capture-body">
<div class="capture-mode-row">
<el-radio-group v-model="captureMode" size="small">
<el-radio-button label="remote">远程浏览器</el-radio-button>
<el-radio-button label="import">真实浏览器导入</el-radio-button>
</el-radio-group>
</div>
<template v-if="captureMode === 'remote'">
<!-- Step 1: URL + Launch -->
<div v-if="!sessionId" class="capture-step">
<h4>步骤 1输入目标登录页面地址</h4>
<el-form @submit.prevent="launchBrowser">
<el-form-item label="登录页 URL">
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
</el-form-item>
<div v-if="showExtraFields" class="capture-extra-fields">
<el-form-item label="登录账号">
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
</el-form-item>
</div>
<div class="capture-launch-row">
<el-button text size="small" @click="showExtraFields = !showExtraFields">
{{ showExtraFields ? '收起' : '自动填充选项' }}
</el-button>
<el-button type="primary" :loading="launching" @click="launchBrowser">
<el-icon><Pointer /></el-icon>
打开远程浏览器
</el-button>
</div>
</el-form>
</div>
<!-- Step 2: Interactive browser via WebSocket -->
<div v-else class="capture-step">
<div class="capture-step">
<div class="capture-step-header">
<h4>步骤 2在浏览器中手动登录</h4>
<h4>真实浏览器导入</h4>
<div class="capture-actions">
<el-button size="small" @click="wsSend({type:'back'})" :disabled="!wsConnected">
<el-icon><Back /></el-icon>
<el-button size="small" :loading="creatingImportSession" type="primary" @click="createBrowserImportSession">
生成导入码
</el-button>
<el-button size="small" @click="wsSend({type:'forward'})" :disabled="!wsConnected">
<el-icon><Right /></el-icon>
</el-button>
<el-button size="small" @click="wsSend({type:'reload'})" :disabled="!wsConnected">
<el-icon><Refresh /></el-icon>
</el-button>
<el-divider direction="vertical" />
<el-button size="small" :loading="extracting" type="primary" @click="extractCredentials">
<el-icon><Search /></el-icon>
提取认证信息
</el-button>
</div>
<div class="ws-status">
<span :class="['ws-dot', wsConnected ? 'connected' : 'disconnected']" />
{{ wsConnected ? '实时' : '连接中…' }}
</div>
</div>
<p class="capture-hint">在下方浏览器中完成登录后点击提取认证信息获取 token / cookie</p>
<p class="capture-hint">
在本机 Chrome/Edge 通过 Cloudflare 并登录后 SmartUp 凭证导入扩展采集并回填
</p>
<!-- WS-based interactive frame -->
<div
ref="frameRef"
class="browser-viewport"
tabindex="0"
:class="{ 'ws-connected': wsConnected }"
@keydown.prevent="onKeydown"
@wheel.prevent="onWheel"
@pointerdown.stop.prevent="onPointerDown"
@pointermove.stop.prevent="onPointerMove"
@pointerup.stop.prevent="onPointerUp"
@pointercancel.stop.prevent="onPointerUp"
@dblclick.prevent="onDblClick"
@contextmenu.prevent
>
<img
v-if="frameUrl"
:src="frameUrl"
class="browser-frame"
alt="远程浏览器"
draggable="false"
@load="onFrameLoad"
/>
<div v-else class="browser-loading">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在连接远程浏览器</p>
<el-form @submit.prevent="createBrowserImportSession">
<el-form-item label="目标登录页 URL">
<el-input v-model="targetUrl" placeholder="https://example.com/login" />
</el-form-item>
</el-form>
<div v-if="importSessionId" class="import-session-panel">
<div class="import-row">
<span class="import-label">SmartUp 地址</span>
<code>{{ smartupOrigin }}</code>
<el-button size="small" text @click="copyText(smartupOrigin)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">导入码</span>
<code>{{ importCode }}</code>
<el-button size="small" text @click="copyText(importCode)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">状态</span>
<span :class="['import-status', importReady ? 'ready' : 'waiting']">
{{ importReady ? '已收到凭证' : '等待扩展提交…' }}
</span>
<el-button size="small" text :loading="importPolling" @click="pollImportSessionOnce">刷新</el-button>
</div>
<div class="import-row">
<span class="import-label">有效期</span>
<span :class="['import-status', importExpired ? 'expired' : 'waiting']">
{{ importExpiresLabel }}
</span>
</div>
</div>
<!-- Extraction results panel -->
<transition name="el-zoom-in-top">
<div v-if="extracted && result" class="candidate-panel">
<div v-if="importReady && importResult" class="candidate-panel">
<div class="candidate-panel-header">
<span>提取到 {{ result.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetExtract">重新提取</el-button>
<span>提取到 {{ importResult.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetImportResult">重新等待</el-button>
</div>
<div v-if="result.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已成功登录后重试
<div v-if="importResult.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已在真实浏览器中成功登录后重试
</div>
<div v-else class="candidate-list">
<div
v-for="(c, i) in result.candidates"
v-for="(c, i) in importResult.candidates"
:key="i"
class="candidate-card"
:class="{ selected: selectedIndex === i }"
@click="selectedIndex = i"
:class="{ selected: importSelectedIndex === i }"
@click="importSelectedIndex = i"
>
<div class="candidate-row">
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = i">
<el-radio :model-value="importSelectedIndex === i" :label="i" @click.stop="importSelectedIndex = i">
<span class="candidate-badge" :class="c.type || 'credential'">
{{ badgeLabel(c.type) }}
</span>
@@ -135,113 +88,21 @@
</div>
<div class="candidate-actions">
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" type="primary" :disabled="selectedIndex < 0" :loading="applyingSelection" @click="confirmSelection">
<el-button size="small" type="primary" :disabled="importSelectedIndex < 0" :loading="applyingSelection" @click="confirmImportSelection">
填入当前表单
</el-button>
</div>
</div>
</transition>
</div>
</template>
<template v-else>
<div class="capture-step">
<div class="capture-step-header">
<h4>真实浏览器导入</h4>
<div class="capture-actions">
<el-button size="small" :loading="creatingImportSession" type="primary" @click="createBrowserImportSession">
生成导入码
</el-button>
</div>
</div>
<p class="capture-hint">
在本机 Chrome/Edge 通过 Cloudflare 并登录后 SmartUp 凭证导入扩展采集并回填
</p>
<el-form @submit.prevent="createBrowserImportSession">
<el-form-item label="目标登录页 URL">
<el-input v-model="targetUrl" placeholder="https://example.com/login" />
</el-form-item>
</el-form>
<div v-if="importSessionId" class="import-session-panel">
<div class="import-row">
<span class="import-label">SmartUp 地址</span>
<code>{{ smartupOrigin }}</code>
<el-button size="small" text @click="copyText(smartupOrigin)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">导入码</span>
<code>{{ importCode }}</code>
<el-button size="small" text @click="copyText(importCode)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">状态</span>
<span :class="['import-status', importReady ? 'ready' : 'waiting']">
{{ importReady ? '已收到凭证' : '等待扩展提交…' }}
</span>
<el-button size="small" text :loading="importPolling" @click="pollImportSessionOnce">刷新</el-button>
</div>
<div class="import-row">
<span class="import-label">有效期</span>
<span :class="['import-status', importExpired ? 'expired' : 'waiting']">
{{ importExpiresLabel }}
</span>
</div>
</div>
<transition name="el-zoom-in-top">
<div v-if="importReady && importResult" class="candidate-panel">
<div class="candidate-panel-header">
<span>提取到 {{ importResult.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetImportResult">重新等待</el-button>
</div>
<div v-if="importResult.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已在真实浏览器中成功登录后重试
</div>
<div v-else class="candidate-list">
<div
v-for="(c, i) in importResult.candidates"
:key="i"
class="candidate-card"
:class="{ selected: importSelectedIndex === i }"
@click="importSelectedIndex = i"
>
<div class="candidate-row">
<el-radio :model-value="importSelectedIndex === i" :label="i" @click.stop="importSelectedIndex = i">
<span class="candidate-badge" :class="c.type || 'credential'">
{{ badgeLabel(c.type) }}
</span>
<span class="candidate-label">{{ c.label }}</span>
</el-radio>
<span v-if="c.confidence" class="candidate-confidence" :class="confClass(c.confidence)">
{{ c.confidence }}%
</span>
</div>
<div class="candidate-preview">
<code>{{ candidatePreview(c) }}</code>
</div>
</div>
</div>
<div class="candidate-actions">
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" type="primary" :disabled="importSelectedIndex < 0" :loading="applyingSelection" @click="confirmImportSelection">
填入当前表单
</el-button>
</div>
</div>
</transition>
</div>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted, nextTick } from 'vue'
import { computed, ref, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
modelValue: boolean
@@ -254,6 +115,40 @@ const emit = defineEmits<{
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; cookie_count?: number; cookie_names?: string[]; new_api_user?: string }): void
}>()
const visible = ref(props.modelValue)
const targetUrl = ref(props.initialUrl || '')
const smartupOrigin = computed(() => location.origin)
const creatingImportSession = ref(false)
const applyingSelection = ref(false)
const importSessionId = ref('')
const importSecret = ref('')
const importPolling = ref(false)
const importReady = ref(false)
const importResult = ref<AuthCaptureResult | null>(null)
const importSelectedIndex = ref(-1)
const importExpiresAt = ref(0)
const nowSeconds = ref(Math.floor(Date.now() / 1000))
const importCode = computed(() => importSessionId.value && importSecret.value
? `${importSessionId.value}:${importSecret.value}`
: '',
)
const importSecondsLeft = computed(() => Math.max(0, Math.floor(importExpiresAt.value - nowSeconds.value)))
const importExpired = computed(() => Boolean(importExpiresAt.value) && importSecondsLeft.value <= 0 && !importReady.value)
const importExpiresLabel = computed(() => {
if (!importExpiresAt.value) return '未生成'
if (importReady.value) return '已完成'
if (importSecondsLeft.value <= 0) return '已过期,请重新生成'
const minutes = Math.floor(importSecondsLeft.value / 60)
const seconds = importSecondsLeft.value % 60
return minutes > 0 ? `${minutes}${seconds} 秒后过期` : `${seconds} 秒后过期`
})
let importPollTimer: number | null = null
let importClockTimer: number | null = null
watch(() => props.modelValue, (v) => { visible.value = v })
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
function candidatePreview(candidate: AuthCaptureCandidate): string {
return candidate.preview || maskValue(candidate.value || '')
}
@@ -268,7 +163,6 @@ function sameCandidate(a: AuthCaptureCandidate, b: AuthCaptureCandidate): boolea
}
function resolveCandidateValue(candidate: AuthCaptureCandidate): string {
// cookie_bundle.value 已是完整 cookie 字符串;cookie.value 是 "name=value" 格式
if (candidate.type === 'cookie') {
return candidate.cookie_value || candidate.value || ''
}
@@ -308,10 +202,8 @@ function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
const preferred = candidates.findIndex((c) => c.type === type)
if (preferred >= 0) return preferred
}
// 优先选完整 cookie bundle(包含 cf_clearance 等完整组合)
const bundle = candidates.findIndex((c) => c.type === 'cookie_bundle')
if (bundle >= 0) return bundle
// 其次选 session cookie
const sessionCookie = candidates.findIndex((c) => c.type === 'cookie' && c.cookie_name === 'session')
if (sessionCookie >= 0) return sessionCookie
const anyCookie = candidates.findIndex((c) => c.type === 'cookie')
@@ -319,100 +211,6 @@ function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
return candidates.length === 1 ? 0 : -1
}
const auth = useAuthStore()
const visible = ref(props.modelValue)
watch(() => props.modelValue, (v) => { visible.value = v })
const targetUrl = ref(props.initialUrl || '')
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
const captureMode = ref<'remote' | 'import'>('remote')
const smartupOrigin = computed(() => location.origin)
const AUTH_CAPTURE_STORAGE_KEY = 'smartup_auth_capture_fields'
function loadSavedFields() {
try {
const raw = localStorage.getItem(AUTH_CAPTURE_STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
if (saved.url) targetUrl.value = saved.url
if (saved.username) { loginUsername.value = saved.username; showExtraFields.value = true }
if (saved.password) { loginPassword.value = saved.password; showExtraFields.value = true }
} catch {}
}
function saveFields() {
try {
localStorage.setItem(AUTH_CAPTURE_STORAGE_KEY, JSON.stringify({
url: targetUrl.value,
username: loginUsername.value,
password: loginPassword.value,
}))
} catch {}
}
// Auto-fill
const showExtraFields = ref(false)
const loginUsername = ref('')
const loginPassword = ref('')
loadSavedFields()
// Session + WS
const sessionId = ref('')
const launching = ref(false)
const extracting = ref(false)
const applyingSelection = ref(false)
const extracted = ref(false)
const result = ref<AuthCaptureResult | null>(null)
const selectedIndex = ref(-1)
const wsConnected = ref(false)
const frameUrl = ref('')
const frameRef = ref<HTMLElement | null>(null)
const creatingImportSession = ref(false)
const importSessionId = ref('')
const importSecret = ref('')
const importPolling = ref(false)
const importReady = ref(false)
const importResult = ref<AuthCaptureResult | null>(null)
const importSelectedIndex = ref(-1)
const importExpiresAt = ref(0)
const nowSeconds = ref(Math.floor(Date.now() / 1000))
const importCode = computed(() => importSessionId.value && importSecret.value
? `${importSessionId.value}:${importSecret.value}`
: '',
)
const importSecondsLeft = computed(() => Math.max(0, Math.floor(importExpiresAt.value - nowSeconds.value)))
const importExpired = computed(() => Boolean(importExpiresAt.value) && importSecondsLeft.value <= 0 && !importReady.value)
const importExpiresLabel = computed(() => {
if (!importExpiresAt.value) return '未生成'
if (importReady.value) return '已完成'
if (importSecondsLeft.value <= 0) return '已过期,请重新生成'
const minutes = Math.floor(importSecondsLeft.value / 60)
const seconds = importSecondsLeft.value % 60
return minutes > 0 ? `${minutes}${seconds} 秒后过期` : `${seconds} 秒后过期`
})
let ws: WebSocket | null = null
let importPollTimer: number | null = null
let importClockTimer: number | null = null
let pointerDown = false
let frameW = 1; let frameH = 1 // natural dimensions of the frame
let prevFrameUrl = '' // previous blob URL pending cleanup
function revokeFrameUrl(url: string) {
if (url) URL.revokeObjectURL(url)
}
function clearFrameUrls() {
revokeFrameUrl(frameUrl.value)
if (prevFrameUrl && prevFrameUrl !== frameUrl.value) {
revokeFrameUrl(prevFrameUrl)
}
frameUrl.value = ''
prevFrameUrl = ''
}
function stopImportPolling() {
if (importPollTimer !== null) {
window.clearInterval(importPollTimer)
@@ -431,10 +229,7 @@ function startImportClock() {
if (importExpired.value) {
stopImportPolling()
ElMessage.warning('导入码已过期,请重新生成')
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
stopImportClock()
}
}, 1000)
}
@@ -446,29 +241,8 @@ function stopImportClock() {
}
}
// ——— Launch ———
async function launchBrowser() {
if (!targetUrl.value) return
saveFields()
launching.value = true
try {
const res = await authCaptureApi.createSession(targetUrl.value)
sessionId.value = res.data.session_id
await nextTick()
connectWs()
} catch (e: any) {
console.error('launch failed', e)
} finally {
launching.value = false
}
}
// ——— Real browser import ———
async function createBrowserImportSession() {
if (!targetUrl.value) return
saveFields()
creatingImportSession.value = true
try {
const res = await authCaptureApi.createImportSession(targetUrl.value)
@@ -516,10 +290,12 @@ function resetImportResult() {
importResult.value = null
importSelectedIndex.value = -1
if (importSessionId.value) {
stopImportPolling()
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
startImportClock()
}
}
@@ -571,190 +347,9 @@ async function copyText(text: string) {
}
}
// ——— WebSocket frame stream ———
function connectWs() {
const token = auth.token
if (!token || !sessionId.value) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}/api/browser-sessions/${sessionId.value}/ws?token=${token}`
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => { wsConnected.value = true }
ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
// Binary JPEG frame — swap in the new URL before cleaning up the old one
const blob = new Blob([evt.data], { type: 'image/jpeg' })
const nextFrameUrl = URL.createObjectURL(blob)
const previousFrameUrl = frameUrl.value
frameUrl.value = nextFrameUrl
prevFrameUrl = previousFrameUrl
if (previousFrameUrl) {
void nextTick(() => {
revokeFrameUrl(previousFrameUrl)
if (prevFrameUrl === previousFrameUrl) {
prevFrameUrl = ''
}
})
}
} else {
// JSON message (init, error, etc.)
try {
const msg = JSON.parse(evt.data)
if (msg.error) {
console.warn('WS error:', msg.error)
}
} catch { /* ignore */ }
}
}
ws.onclose = () => {
wsConnected.value = false
ws = null
clearFrameUrls()
}
ws.onerror = () => {
wsConnected.value = false
}
}
function wsSend(data: Record<string, any>) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
function onFrameLoad(e: Event) {
const img = e.target as HTMLImageElement
frameW = img.naturalWidth || 1
frameH = img.naturalHeight || 1
}
// ——— Event forwarding (via WS) ———
function scalePoint(e: PointerEvent): { x: number; y: number } {
const el = frameRef.value
if (!el) return { x: e.clientX, y: e.clientY }
const rect = el.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * frameW,
y: ((e.clientY - rect.top) / rect.height) * frameH,
}
}
function onPointerDown(e: PointerEvent) {
frameRef.value?.focus({ preventScroll: true })
pointerDown = true
const p = scalePoint(e)
wsSend({ type: 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onPointerMove(e: PointerEvent) {
if (!pointerDown) return
const p = scalePoint(e)
wsSend({ type: 'mousemove', x: p.x, y: p.y })
}
function onPointerUp(e: PointerEvent) {
if (!pointerDown) return
pointerDown = false
const p = scalePoint(e)
wsSend({ type: 'mouseup', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onDblClick(e: MouseEvent) {
const p = scalePoint(e as unknown as PointerEvent)
wsSend({ type: 'dblclick', x: p.x, y: p.y })
}
function onWheel(e: WheelEvent) {
const p = scalePoint(e as unknown as PointerEvent)
wsSend({ type: 'scroll', delta_x: e.deltaX, delta_y: e.deltaY, x: p.x, y: p.y })
}
function onKeydown(e: KeyboardEvent) {
const keyMap: Record<string, string> = {
Enter: 'Enter', Backspace: 'Backspace', Escape: 'Escape', Tab: 'Tab',
ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
}
if (keyMap[e.key]) {
wsSend({ type: 'key', key: keyMap[e.key] })
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
wsSend({ type: 'type', text: e.key })
}
}
// ——— Extraction ———
async function extractCredentials() {
if (!sessionId.value) return
extracting.value = true
try {
const res = await authCaptureApi.extract(sessionId.value)
result.value = res.data
extracted.value = true
selectedIndex.value = defaultCandidateIndex(res.data.candidates)
} catch (e: any) {
console.error('extract failed', e)
} finally {
extracting.value = false
}
}
async function confirmSelection() {
if (selectedIndex.value < 0 || !result.value || !sessionId.value) return
const selectedCandidate = result.value.candidates[selectedIndex.value]
applyingSelection.value = true
try {
const rawResult = await authCaptureApi.extract(sessionId.value, { includeRaw: true })
const fullCandidate = rawResult.data.candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
if (!fullCandidate) {
ElMessage.error('未找到完整认证信息,请重新提取后再试')
return
}
const resolvedValue = resolveCandidateValue(fullCandidate)
if (!resolvedValue) {
ElMessage.error('认证信息为空,请重新提取后再试')
return
}
emit('select', {
type: fullCandidate.type,
value: resolvedValue,
source: fullCandidate.source,
cookie_name: fullCandidate.cookie_name,
cookie_value: fullCandidate.cookie_value,
cookie_count: fullCandidate.cookie_count,
cookie_names: fullCandidate.cookie_names,
new_api_user: resolveNewApiUser(rawResult.data, fullCandidate),
})
closeDialog()
} catch (e: any) {
console.error('apply extract failed', e)
ElMessage.error(e?.response?.data?.detail || '获取完整认证信息失败')
} finally {
applyingSelection.value = false
}
}
function resetExtract() {
extracted.value = false
result.value = null
selectedIndex.value = -1
}
async function handleClose() {
function handleClose() {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
}
closeDialog()
}
@@ -763,28 +358,6 @@ function closeDialog() {
emit('update:modelValue', false)
}
function disconnectWs() {
if (ws) {
ws.onclose = null
ws.onmessage = null
ws.close()
ws = null
}
wsConnected.value = false
clearFrameUrls()
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
authCaptureApi.closeSession(sessionId.value).catch(() => {})
}
})
// ——— Helpers ———
function badgeLabel(type: string): string {
return {
bearer_token: 'Bearer',
@@ -794,23 +367,25 @@ function badgeLabel(type: string): string {
credential: 'Key',
}[type] || type
}
function confClass(s: number): string {
return s >= 80 ? 'conf-high' : s >= 50 ? 'conf-mid' : 'conf-low'
}
function maskValue(v: string): string {
if (!v || v.length <= 8) return '***'
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
return v.slice(0, 8) + '…' + v.slice(-6)
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
})
</script>
<style scoped>
.auth-capture-body { min-height: 350px; }
.capture-mode-row {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.capture-step { padding: 4px 0; }
.capture-step-header {
display: flex; justify-content: space-between; align-items: center;
@@ -819,12 +394,6 @@ function maskValue(v: string): string {
.capture-step-header h4 { margin: 0; }
.capture-actions { display: flex; gap: 6px; align-items: center; }
.capture-hint { color: var(--el-text-color-secondary); font-size: 0.85rem; margin: 0 0 8px; }
.capture-extra-fields {
margin-top: 8px; padding: 8px; background: transparent; border-radius: 6px;
}
.capture-launch-row {
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
}
.import-session-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
@@ -853,23 +422,6 @@ function maskValue(v: string): string {
.import-status.ready { color: #52c41a; }
.import-status.waiting { color: var(--el-text-color-secondary); }
.import-status.expired { color: #ff4d4f; }
.ws-status { display: flex; align-items: center; gap: 4px; font-size: 0.78rem; color: var(--el-text-color-secondary); }
.ws-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.ws-dot.connected { background: #52c41a; }
.ws-dot.disconnected { background: #ff4d4f; }
.browser-viewport {
border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
background: #000; cursor: crosshair; outline: none; position: relative; min-height: 300px;
}
.browser-frame {
display: block; width: 100%; height: auto;
user-select: none; -webkit-user-drag: none;
}
.browser-loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 300px;
color: var(--el-text-color-secondary); gap: 12px;
}
.candidate-panel {
margin-top: 12px; border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
}
@@ -885,7 +437,7 @@ function maskValue(v: string): string {
}
.candidate-card:last-child { border-bottom: none; }
.candidate-card:hover, .candidate-card.selected { background: var(--el-color-primary-light-9); }
.candidate-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
.candidate-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; gap: 8px; }
.candidate-badge {
display: inline-block; padding: 0 6px; border-radius: 4px;
font-size: 0.72rem; font-weight: 600; margin-right: 6px;
@@ -896,7 +448,7 @@ function maskValue(v: string): string {
.candidate-badge.api_key { background: #f0f5ff; color: #2f54eb; }
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
.candidate-label { font-size: 0.82rem; color: var(--el-text-color-secondary); }
.candidate-confidence { font-size: 0.75rem; font-weight: 600; padding: 1px 6px; border-radius: 8px; }
.candidate-confidence { font-size: 0.75rem; font-weight: 600; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
.conf-high { background: #f6ffed; color: #52c41a; }
.conf-mid { background: #fff7e6; color: #d48806; }
.conf-low { background: #fff2f0; color: #ff4d4f; }