feat: support real browser auth import
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 Token;Cookie 只能在确认上游支持 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 Token;Cookie 只能在确认上游支持 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
|
||||
|
||||
Reference in New Issue
Block a user