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
+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