Remove server remote browser support

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