Remove server remote browser support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user