Remove server remote browser support
This commit is contained in:
@@ -379,7 +379,7 @@ export const logsApi = {
|
||||
}
|
||||
|
||||
// ——— Custom Pages ———
|
||||
export type CustomPageAccessMode = 'direct' | 'proxy' | 'remote_browser'
|
||||
export type CustomPageAccessMode = 'direct' | 'proxy'
|
||||
|
||||
export interface CustomPageData {
|
||||
id: number
|
||||
@@ -408,8 +408,8 @@ export interface CustomPageForm {
|
||||
icon: string
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
access_mode: CustomPageAccessMode
|
||||
use_proxy?: boolean
|
||||
access_mode?: CustomPageAccessMode
|
||||
description?: string
|
||||
login_username?: string
|
||||
login_password?: string
|
||||
@@ -427,70 +427,9 @@ export const customPagesApi = {
|
||||
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
|
||||
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
||||
refreshAuth: (id: number) => api.post<{ success: boolean; message: string; warning?: string }>(`/api/custom-pages/${id}/refresh-auth`),
|
||||
}
|
||||
|
||||
// ——— Remote browser sessions ———
|
||||
export interface BrowserTabData {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface BrowserSessionData {
|
||||
id: string
|
||||
custom_page_id: number
|
||||
url: string
|
||||
title: string
|
||||
active_tab_id?: string
|
||||
tabs?: BrowserTabData[]
|
||||
tab_revision?: number
|
||||
}
|
||||
|
||||
export type BrowserEventPayload =
|
||||
| { type: 'click' | 'dblclick' | 'mousemove' | 'mousedown' | 'mouseup'; x: number; y: number; button?: 'left' | 'right' | 'middle' }
|
||||
| { type: 'type'; text: string }
|
||||
| { type: 'key'; key: string }
|
||||
| { type: 'scroll'; delta_x: number; delta_y: number; x?: number; y?: number }
|
||||
| { type: 'reload' | 'back' | 'forward' }
|
||||
| { type: 'resize'; width: number; height: number }
|
||||
|
||||
export const browserSessionsApi = {
|
||||
create: (data: { custom_page_id: number; width: number; height: number }) =>
|
||||
api.post<BrowserSessionData>('/api/browser-sessions', data),
|
||||
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
|
||||
event: (id: string, data: BrowserEventPayload) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||
activateTab: (id: string, tabId: string) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}/activate`),
|
||||
closeTab: (id: string, tabId: string) =>
|
||||
api.delete<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}`),
|
||||
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
||||
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
|
||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||
autofillLogin: (id: string) => api.post<{ success: boolean; message: string }>(`/api/browser-sessions/${id}/autofill-login`),
|
||||
clearProfile: (customPageId: number) => api.delete(`/api/browser-sessions/profiles/${customPageId}`),
|
||||
screenshotUrl: (id: string, token?: string) => {
|
||||
const params = new URLSearchParams({ t: String(Date.now()) })
|
||||
if (token) params.set('token', token)
|
||||
return `/api/browser-sessions/${id}/screenshot?${params.toString()}`
|
||||
},
|
||||
/** Build a WebSocket URL for the streaming endpoint. */
|
||||
wsUrl: (id: string, token?: string) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams()
|
||||
if (token) params.set('token', token)
|
||||
return `${proto}//${location.host}/api/browser-sessions/${id}/ws?${params.toString()}`
|
||||
},
|
||||
}
|
||||
|
||||
// ——— Auth Capture ———
|
||||
export interface AuthCaptureSession {
|
||||
session_id: string
|
||||
ws_url: string
|
||||
}
|
||||
|
||||
export interface BrowserImportSession {
|
||||
session_id: string
|
||||
secret: string
|
||||
@@ -527,24 +466,10 @@ export interface BrowserImportStatus {
|
||||
}
|
||||
|
||||
export const authCaptureApi = {
|
||||
createSession: (url: string, width?: number, height?: number) =>
|
||||
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
|
||||
extract: (sessionId: string, options?: { includeRaw?: boolean }) =>
|
||||
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`, {
|
||||
params: options?.includeRaw ? { include_raw: true } : undefined,
|
||||
}),
|
||||
closeSession: (sessionId: string) =>
|
||||
api.delete(`/api/auth-capture/sessions/${sessionId}`),
|
||||
createImportSession: (targetUrl: string) =>
|
||||
api.post<BrowserImportSession>('/api/auth-capture/import-sessions', { target_url: targetUrl }),
|
||||
importSessionStatus: (sessionId: string, options?: { includeRaw?: boolean }) =>
|
||||
api.get<BrowserImportStatus>(`/api/auth-capture/import-sessions/${sessionId}`, {
|
||||
params: options?.includeRaw ? { include_raw: true } : undefined,
|
||||
}),
|
||||
wsUrl: (sessionId: string, token?: string) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams()
|
||||
if (token) params.set('token', token)
|
||||
return `${proto}//${location.host}/api/browser-sessions/${sessionId}/ws?${params.toString()}`
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -20,7 +20,7 @@ const router = createRouter({
|
||||
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||
{ path: 'page/:id', component: () => import('@/views/PageViewer.vue') },
|
||||
{ path: 'page/:id', redirect: '/custom-pages' },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="shell-page page-section custom-pages-shell">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Embedded Surfaces</p>
|
||||
<h2 class="page-title">自定义页面</h2>
|
||||
<p class="page-desc">嵌入外部网页到侧边栏,统一管理上游平台</p>
|
||||
<p class="page-kicker">External Consoles</p>
|
||||
<h2 class="page-title">上游网址管理</h2>
|
||||
<p class="page-desc">维护外部控制台入口,点击后使用本机浏览器打开</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 添加页面
|
||||
<el-icon><Plus /></el-icon> 添加网址
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
<div class="card-url" :title="page.url">{{ page.url }}</div>
|
||||
</div>
|
||||
<div class="tag-group">
|
||||
<el-tag v-if="page.access_mode === 'proxy' || page.use_proxy" size="small" type="warning" class="proxy-tag">代理</el-tag>
|
||||
<el-tag v-else-if="page.access_mode === 'remote_browser'" size="small" type="success" class="proxy-tag">远程浏览器</el-tag>
|
||||
<el-tag v-if="!page.enabled" size="small" type="info" class="disabled-tag">已停用</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +32,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card-actions">
|
||||
<el-button size="small" text type="primary" @click="openViewer(page)">
|
||||
<el-button size="small" text type="primary" @click="openExternalPage(page)">
|
||||
<el-icon><Monitor /></el-icon> 打开
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openEdit(page)">
|
||||
@@ -49,8 +47,8 @@
|
||||
|
||||
<div v-if="!loading && list.length === 0" class="empty-state">
|
||||
<el-icon :size="48" class="empty-icon"><Monitor /></el-icon>
|
||||
<p>还没有自定义页面</p>
|
||||
<p class="empty-sub">添加后可在侧边栏快速访问上游管理平台</p>
|
||||
<p>还没有上游网址</p>
|
||||
<p class="empty-sub">添加后可在侧边栏快速打开外部控制台</p>
|
||||
<el-button type="primary" @click="openCreate">立即添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +56,7 @@
|
||||
<!-- Create / Edit dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingId ? '编辑页面' : '添加自定义页面'"
|
||||
:title="editingId ? '编辑网址' : '添加上游网址'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
@@ -86,72 +84,9 @@
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" style="width:140px" />
|
||||
<span class="form-hint">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问模式">
|
||||
<el-radio-group v-model="form.access_mode" class="access-mode-group">
|
||||
<el-radio-button label="direct">直接嵌入</el-radio-button>
|
||||
<el-radio-button label="proxy">代理</el-radio-button>
|
||||
<el-radio-button label="remote_browser">远程浏览器</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="form-hint mode-hint">远程浏览器适合 Cookie、CSP、复杂 SPA 或拒绝 iframe 的站点。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游">
|
||||
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%" @change="handleLinkedUpstreamChange">
|
||||
<el-option v-for="u in upstreamList" :key="u.id" :label="`${u.name} (${u.base_url})`" :value="u.id" />
|
||||
</el-select>
|
||||
<div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.access_mode === 'remote_browser' && form.linked_upstream_id" label="上游类型">
|
||||
<el-select v-model="form.upstream_platform" style="width:100%">
|
||||
<el-option label="Sub2API" value="sub2api" />
|
||||
<el-option label="New-API" value="new-api" />
|
||||
</el-select>
|
||||
<div class="form-hint">保存后会同步该关联上游的接口路径,刷新凭证时按此类型选择 Token 或 Cookie。</div>
|
||||
</el-form-item>
|
||||
<div class="login-section">
|
||||
<div class="login-section-head">
|
||||
<span>登录自动填充</span>
|
||||
<el-switch v-model="form.login_autofill_enabled" @change="loginAutofillTouched = true" />
|
||||
</div>
|
||||
<div class="form-hint login-hint">仅远程浏览器模式会执行;不填写提交按钮 selector 时只填账号密码。</div>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_username" autocomplete="username" placeholder="登录账号、邮箱或用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<div class="password-field">
|
||||
<el-input
|
||||
v-model="form.login_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:disabled="form.login_password_clear"
|
||||
:placeholder="editingId && form.login_password_configured ? '留空保持原密码' : '登录密码'"
|
||||
/>
|
||||
<el-checkbox
|
||||
v-if="editingId && form.login_password_configured"
|
||||
v-model="form.login_password_clear"
|
||||
@change="form.login_password = ''"
|
||||
>
|
||||
清空已保存密码
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-collapse class="selector-collapse">
|
||||
<el-collapse-item title="高级 selector" name="selectors">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_username_selector" placeholder="例:input[name='email']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.login_password_selector" placeholder="例:input[type='password']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提交按钮">
|
||||
<el-input v-model="form.login_submit_selector" placeholder="可选,例:button[type='submit']" />
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -163,7 +98,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import {
|
||||
@@ -171,9 +105,7 @@ import {
|
||||
SetUp, Reading, Cpu, DataLine, Grid, Connection,
|
||||
Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, upstreamsApi, type CustomPageAccessMode, type CustomPageData, type UpstreamData } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
import { customPagesApi, type CustomPageData, type CustomPageForm } from '@/api'
|
||||
|
||||
// ---- icon map ----
|
||||
const iconMap: Record<string, any> = {
|
||||
@@ -195,35 +127,19 @@ const iconMap: Record<string, any> = {
|
||||
|
||||
// ---- state ----
|
||||
const list = ref<CustomPageData[]>([])
|
||||
const upstreamList = ref<UpstreamData[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const loginAutofillTouched = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
type UpstreamPlatform = 'sub2api' | 'new-api'
|
||||
|
||||
type PageFormState = {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
access_mode: CustomPageAccessMode
|
||||
description: string
|
||||
login_username: string
|
||||
login_password: string
|
||||
login_username_selector: string
|
||||
login_password_selector: string
|
||||
login_submit_selector: string
|
||||
login_autofill_enabled: boolean
|
||||
login_password_configured: boolean
|
||||
login_password_clear: boolean
|
||||
linked_upstream_id: number | null
|
||||
upstream_platform: UpstreamPlatform
|
||||
}
|
||||
|
||||
const defaultForm = (): PageFormState => ({
|
||||
@@ -232,19 +148,7 @@ const defaultForm = (): PageFormState => ({
|
||||
icon: 'Link',
|
||||
sort_order: 0,
|
||||
enabled: true,
|
||||
use_proxy: false,
|
||||
access_mode: 'direct',
|
||||
description: '',
|
||||
login_username: '',
|
||||
login_password: '',
|
||||
login_username_selector: '',
|
||||
login_password_selector: '',
|
||||
login_submit_selector: '',
|
||||
login_autofill_enabled: false,
|
||||
login_password_configured: false,
|
||||
login_password_clear: false,
|
||||
linked_upstream_id: null,
|
||||
upstream_platform: 'sub2api',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
@@ -255,9 +159,8 @@ const rules = {
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [pagesRes, upstreamsRes] = await Promise.all([customPagesApi.list(), upstreamsApi.list()])
|
||||
const pagesRes = await customPagesApi.list()
|
||||
list.value = pagesRes.data
|
||||
upstreamList.value = upstreamsRes.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -265,105 +168,41 @@ async function loadList() {
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
loginAutofillTouched.value = false
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(page: CustomPageData) {
|
||||
editingId.value = page.id
|
||||
loginAutofillTouched.value = false
|
||||
form.value = {
|
||||
name: page.name,
|
||||
url: page.url,
|
||||
icon: page.icon,
|
||||
sort_order: page.sort_order,
|
||||
enabled: page.enabled,
|
||||
use_proxy: page.access_mode === 'proxy' || page.use_proxy,
|
||||
access_mode: page.access_mode || (page.use_proxy ? 'proxy' : 'direct'),
|
||||
description: page.description || '',
|
||||
login_username: page.login_username || '',
|
||||
login_password: '',
|
||||
login_username_selector: page.login_username_selector || '',
|
||||
login_password_selector: page.login_password_selector || '',
|
||||
login_submit_selector: page.login_submit_selector || '',
|
||||
login_autofill_enabled: page.login_autofill_enabled,
|
||||
login_password_configured: page.login_password_configured,
|
||||
login_password_clear: false,
|
||||
linked_upstream_id: page.linked_upstream_id ?? null,
|
||||
upstream_platform: detectUpstreamPlatform(page.linked_upstream_id ?? null),
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function selectedUpstream(id = form.value.linked_upstream_id): UpstreamData | undefined {
|
||||
return upstreamList.value.find((u) => u.id === id)
|
||||
}
|
||||
|
||||
function detectUpstreamPlatform(id: number | null): UpstreamPlatform {
|
||||
const upstream = selectedUpstream(id)
|
||||
if (!upstream) return 'sub2api'
|
||||
const cfg = upstream.auth_config_masked || {}
|
||||
if (
|
||||
(upstream.groups_endpoint || '').replace(/\/+$/, '') === '/api/user/self/groups' ||
|
||||
cfg.login_path === '/api/user/login'
|
||||
) {
|
||||
return 'new-api'
|
||||
}
|
||||
return 'sub2api'
|
||||
}
|
||||
|
||||
function handleLinkedUpstreamChange(id: number | null) {
|
||||
form.value.upstream_platform = detectUpstreamPlatform(id)
|
||||
}
|
||||
|
||||
async function syncLinkedUpstreamPlatform() {
|
||||
if (form.value.access_mode !== 'remote_browser' || !form.value.linked_upstream_id) return
|
||||
if (form.value.upstream_platform === 'new-api') {
|
||||
await upstreamsApi.update(form.value.linked_upstream_id, {
|
||||
api_prefix: '',
|
||||
groups_endpoint: '/api/user/self/groups',
|
||||
rate_endpoint: '/api/user/self/groups',
|
||||
balance_endpoint: '/api/user/self',
|
||||
balance_response_path: 'data.quota',
|
||||
balance_divisor: 500000,
|
||||
auth_config: { login_path: '/api/user/login', username_field: 'username' },
|
||||
} as any)
|
||||
return
|
||||
}
|
||||
await upstreamsApi.update(form.value.linked_upstream_id, {
|
||||
api_prefix: '/api/v1',
|
||||
groups_endpoint: '/groups/available',
|
||||
rate_endpoint: '/groups/rates',
|
||||
balance_endpoint: '/auth/me',
|
||||
balance_response_path: 'data.balance',
|
||||
balance_divisor: 1,
|
||||
auth_config: { login_path: '/auth/login', username_field: 'email' },
|
||||
} as any)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { login_password_configured: _passwordConfigured, ...payload } = {
|
||||
...form.value,
|
||||
use_proxy: form.value.access_mode === 'proxy',
|
||||
const savePayload: CustomPageForm = {
|
||||
name: form.value.name,
|
||||
url: form.value.url,
|
||||
icon: form.value.icon,
|
||||
sort_order: form.value.sort_order,
|
||||
enabled: form.value.enabled,
|
||||
description: form.value.description,
|
||||
}
|
||||
delete (payload as any).upstream_platform
|
||||
const hasNewLoginCredentials = Boolean(payload.login_username?.trim() && payload.login_password?.trim())
|
||||
if (!loginAutofillTouched.value && hasNewLoginCredentials) {
|
||||
payload.login_autofill_enabled = true
|
||||
}
|
||||
const { login_autofill_enabled: _autofillEnabled, ...payloadWithoutAutofill } = payload
|
||||
const savePayload = loginAutofillTouched.value || hasNewLoginCredentials ? payload : payloadWithoutAutofill
|
||||
if (editingId.value) {
|
||||
await customPagesApi.update(editingId.value, savePayload)
|
||||
} else {
|
||||
await customPagesApi.create(savePayload)
|
||||
}
|
||||
await syncLinkedUpstreamPlatform()
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
@@ -386,8 +225,8 @@ async function confirmDelete(page: CustomPageData) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openViewer(page: CustomPageData) {
|
||||
router.push(`/page/${page.id}`)
|
||||
function openExternalPage(page: CustomPageData) {
|
||||
window.open(page.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
@@ -459,7 +298,6 @@ onMounted(loadList)
|
||||
}
|
||||
.disabled-tag { flex-shrink: 0; }
|
||||
.tag-group { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.proxy-tag { flex-shrink: 0; }
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
@@ -482,38 +320,6 @@ onMounted(loadList)
|
||||
}
|
||||
|
||||
.form-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
|
||||
.mode-hint { margin-left: 0; margin-top: 6px; line-height: 1.4; }
|
||||
.access-mode-group { max-width: 100%; }
|
||||
|
||||
.login-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
padding-left: 90px;
|
||||
}
|
||||
.login-hint {
|
||||
margin: 0 0 12px 90px;
|
||||
}
|
||||
.selector-collapse {
|
||||
margin-left: 90px;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.password-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user