feat: support real browser auth import

This commit is contained in:
liumangmang
2026-06-02 13:51:29 +08:00
parent f4d16a4c01
commit 84148f4a69
22 changed files with 1651 additions and 111 deletions
+19
View File
@@ -491,6 +491,12 @@ export interface AuthCaptureSession {
ws_url: string
}
export interface BrowserImportSession {
session_id: string
secret: string
expires_in_seconds: number
}
export interface AuthCaptureCandidate {
type: 'bearer_token' | 'cookie' | 'cookie_bundle' | 'credential' | 'api_key'
source: string
@@ -513,6 +519,13 @@ export interface AuthCaptureResult {
candidates: AuthCaptureCandidate[]
}
export interface BrowserImportStatus {
session_id: string
ready: boolean
expires_at: number
result: AuthCaptureResult | null
}
export const authCaptureApi = {
createSession: (url: string, width?: number, height?: number) =>
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
@@ -522,6 +535,12 @@ export const authCaptureApi = {
}),
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()
+308 -1
View File
@@ -8,6 +8,14 @@
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>
@@ -134,12 +142,102 @@
</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 { ref, watch, onUnmounted, nextTick } from 'vue'
import { computed, ref, watch, onUnmounted, nextTick } 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'
@@ -148,6 +246,7 @@ import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
modelValue: boolean
initialUrl?: string
preferredTypes?: AuthCaptureCandidate['type'][]
}>()
const emit = defineEmits<{
@@ -205,6 +304,10 @@ function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureC
function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
if (candidates.length === 0) return -1
for (const type of props.preferredTypes || []) {
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
@@ -222,6 +325,8 @@ 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'
@@ -264,8 +369,33 @@ 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
@@ -283,6 +413,39 @@ function clearFrameUrls() {
prevFrameUrl = ''
}
function stopImportPolling() {
if (importPollTimer !== null) {
window.clearInterval(importPollTimer)
importPollTimer = null
}
importPolling.value = false
}
function startImportClock() {
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
}
nowSeconds.value = Math.floor(Date.now() / 1000)
importClockTimer = window.setInterval(() => {
nowSeconds.value = Math.floor(Date.now() / 1000)
if (importExpired.value) {
stopImportPolling()
ElMessage.warning('导入码已过期,请重新生成')
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
}
}, 1000)
}
function stopImportClock() {
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
}
// ——— Launch ———
async function launchBrowser() {
@@ -301,6 +464,113 @@ async function launchBrowser() {
}
}
// ——— Real browser import ———
async function createBrowserImportSession() {
if (!targetUrl.value) return
saveFields()
creatingImportSession.value = true
try {
const res = await authCaptureApi.createImportSession(targetUrl.value)
importSessionId.value = res.data.session_id
importSecret.value = res.data.secret
importExpiresAt.value = Math.floor(Date.now() / 1000) + res.data.expires_in_seconds
importReady.value = false
importResult.value = null
importSelectedIndex.value = -1
ElMessage.success('导入码已生成,请在浏览器扩展中粘贴')
stopImportPolling()
startImportClock()
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
void pollImportSessionOnce()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '生成导入码失败')
} finally {
creatingImportSession.value = false
}
}
async function pollImportSessionOnce() {
if (!importSessionId.value) return
try {
const res = await authCaptureApi.importSessionStatus(importSessionId.value)
importExpiresAt.value = res.data.expires_at
if (res.data.ready && res.data.result) {
importReady.value = true
importResult.value = res.data.result
importSelectedIndex.value = defaultCandidateIndex(res.data.result.candidates)
stopImportPolling()
stopImportClock()
}
} catch (e: any) {
stopImportPolling()
ElMessage.error(e?.response?.data?.detail || '导入会话已失效')
}
}
function resetImportResult() {
importReady.value = false
importResult.value = null
importSelectedIndex.value = -1
if (importSessionId.value) {
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
}
}
async function confirmImportSelection() {
if (importSelectedIndex.value < 0 || !importResult.value || !importSessionId.value) return
const selectedCandidate = importResult.value.candidates[importSelectedIndex.value]
applyingSelection.value = true
try {
const rawResult = await authCaptureApi.importSessionStatus(importSessionId.value, { includeRaw: true })
const candidates = rawResult.data.result?.candidates || []
const fullCandidate = candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
if (!rawResult.data.result || !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.result, fullCandidate),
})
closeDialog()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '获取完整认证信息失败')
} finally {
applyingSelection.value = false
}
}
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
// ——— WebSocket frame stream ———
function connectWs() {
@@ -479,6 +749,8 @@ function resetExtract() {
}
async function handleClose() {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
@@ -503,6 +775,8 @@ function disconnectWs() {
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
authCaptureApi.closeSession(sessionId.value).catch(() => {})
@@ -532,6 +806,11 @@ function maskValue(v: string): string {
<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;
@@ -546,6 +825,34 @@ function maskValue(v: string): string {
.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;
padding: 10px;
margin: 10px 0;
background: var(--el-fill-color-light);
}
.import-row {
display: grid;
grid-template-columns: 90px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 32px;
}
.import-row code {
overflow-wrap: anywhere;
color: var(--el-text-color-primary);
}
.import-label {
color: var(--el-text-color-secondary);
font-size: 0.82rem;
}
.import-status {
font-size: 0.86rem;
}
.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; }
+61 -1
View File
@@ -98,11 +98,18 @@
<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%">
<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>
@@ -196,6 +203,8 @@ 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
@@ -214,6 +223,7 @@ type PageFormState = {
login_password_configured: boolean
login_password_clear: boolean
linked_upstream_id: number | null
upstream_platform: UpstreamPlatform
}
const defaultForm = (): PageFormState => ({
@@ -234,6 +244,7 @@ const defaultForm = (): PageFormState => ({
login_password_configured: false,
login_password_clear: false,
linked_upstream_id: null,
upstream_platform: 'sub2api',
})
const form = ref(defaultForm())
const rules = {
@@ -280,10 +291,57 @@ function openEdit(page: CustomPageData) {
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
@@ -293,6 +351,7 @@ async function handleSave() {
...form.value,
use_proxy: form.value.access_mode === 'proxy',
}
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
@@ -304,6 +363,7 @@ async function handleSave() {
} else {
await customPagesApi.create(savePayload)
}
await syncLinkedUpstreamPlatform()
ElMessage.success('保存成功')
dialogVisible.value = false
loadList()
+26 -12
View File
@@ -120,8 +120,7 @@
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
<el-option label="Sub2API" value="sub2api" />
<el-option label="New-API (管理员Key)" value="new-api" />
<el-option label="New-API (普通账号)" value="new-api-user" />
<el-option label="New-API" value="new-api-user" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
@@ -405,6 +404,7 @@
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl"
:preferred-types="authCapturePreferredTypes"
@select="handleAuthCaptureSelect"
/>
</div>
@@ -416,7 +416,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
import { upstreamsApi, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api'
import { upstreamsApi, type AuthCaptureCandidate, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -459,6 +459,12 @@ const authCaptureInitialUrl = computed(() => {
return base + '/login'
})
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
if (quickPlatform.value === 'new-api-user') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
})
function openAuthCapture() {
authCaptureVisible.value = true
}
@@ -480,6 +486,9 @@ function handleAuthCaptureSelect(candidate: {
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
@@ -498,6 +507,9 @@ function handleAuthCaptureSelect(candidate: {
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
@@ -542,14 +554,6 @@ function handlePlatformChange(val: string) {
form.value.balance_endpoint = '/auth/me'
form.value.balance_response_path = 'data.balance'
form.value.balance_divisor = 1.0
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
form.value.rate_endpoint = '/api/option/?key=GroupRatio'
form.value.auth_type = 'bearer'
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else if (val === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
@@ -609,6 +613,16 @@ const healthyRate = computed(() => {
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
function isNewApiUserUpstream(row: UpstreamData | null) {
if (!row) return false
return row.api_prefix === ''
&& (
row.groups_endpoint === '/api/user/self/groups'
|| row.auth_config_masked?.login_path === '/api/user/login'
|| Boolean(row.auth_config_masked?.new_api_user)
)
}
const latestCheckedAt = computed(() => {
const times = list.value
.map((item) => item.last_checked_at)
@@ -799,7 +813,7 @@ async function openKeyGenerate(row: UpstreamData) {
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
endpoint: isNewApiUserUpstream(row) ? '/api/token' : '/keys',
}
useKeyExpiry.value = false
keyExpiresDays.value = 30