feat: support real browser auth import
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user