feat: auth capture — interactive browser, CDP header capture, cookie auth
- AuthCaptureDialog: full WS screenshot stream + mouse/keyboard/scroll events - Backend auth_capture: CDP Network.requestWillBeSent for Authorization headers - Candidate scoring: confidence 0-95%, preview (masked), auth_headers section - Upstream form: add 'Cookie' auth type, handle cookie selection - UpstreamClient: support auth_type=cookie with Cookie header - No secrets logged at DEBUG or higher
This commit is contained in:
@@ -37,6 +37,7 @@ class CaptureExtractResponse(BaseModel):
|
|||||||
cookies: list[dict] = []
|
cookies: list[dict] = []
|
||||||
storage: dict[str, str] = {}
|
storage: dict[str, str] = {}
|
||||||
session_storage: dict[str, str] = {}
|
session_storage: dict[str, str] = {}
|
||||||
|
auth_headers: list[dict] = []
|
||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,23 @@ from typing import Any
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys likely to contain auth tokens in storage
|
||||||
|
TOKEN_KEYS = frozenset({
|
||||||
|
"token", "access_token", "accessToken", "jwt", "auth_token", "authToken",
|
||||||
|
"refresh_token", "refreshToken", "id_token", "session_token",
|
||||||
|
})
|
||||||
|
SECRET_KEYS = frozenset({
|
||||||
|
"secret", "api_key", "apiKey", "apikey",
|
||||||
|
})
|
||||||
|
SESSION_COOKIE_NAMES = frozenset({
|
||||||
|
"session", "token", "jwt", "sid", "auth", "connect.sid",
|
||||||
|
"gin_session", "tdc_itoken", "sessionid",
|
||||||
|
"access_token", "refresh_token",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
async def extract_cookies(session: Any) -> list[dict[str, Any]]:
|
async def extract_cookies(session: Any) -> list[dict[str, Any]]:
|
||||||
"""Extract cookies from the browser context."""
|
"""Extract all cookies from the browser context."""
|
||||||
cookies = await session.context.cookies()
|
cookies = await session.context.cookies()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -24,7 +38,6 @@ async def extract_cookies(session: Any) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
async def extract_local_storage(page: Any) -> dict[str, str]:
|
async def extract_local_storage(page: Any) -> dict[str, str]:
|
||||||
"""Extract all localStorage items from the page origin."""
|
|
||||||
try:
|
try:
|
||||||
raw = await page.evaluate("() => JSON.stringify(window.localStorage)")
|
raw = await page.evaluate("() => JSON.stringify(window.localStorage)")
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
@@ -36,7 +49,6 @@ async def extract_local_storage(page: Any) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
async def extract_session_storage(page: Any) -> dict[str, str]:
|
async def extract_session_storage(page: Any) -> dict[str, str]:
|
||||||
"""Extract all sessionStorage items from the page origin."""
|
|
||||||
try:
|
try:
|
||||||
raw = await page.evaluate("() => JSON.stringify(window.sessionStorage)")
|
raw = await page.evaluate("() => JSON.stringify(window.sessionStorage)")
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
@@ -47,25 +59,71 @@ async def extract_session_storage(page: Any) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_request_headers(page: Any) -> list[dict[str, str]]:
|
||||||
|
"""Capture Authorization headers from network requests via CDP.
|
||||||
|
|
||||||
|
Uses Chrome DevTools Protocol to subscribe to Network.requestWillBeSent
|
||||||
|
events and extract Authorization / X-API-Key headers from captured
|
||||||
|
requests. Only catches requests made *after* CDP is enabled.
|
||||||
|
"""
|
||||||
|
captured: list[dict[str, str]] = []
|
||||||
|
cdp = None
|
||||||
|
try:
|
||||||
|
cdp = await page.context.new_cdp_session(page)
|
||||||
|
await cdp.send("Network.enable")
|
||||||
|
|
||||||
|
def on_request(params: dict) -> None:
|
||||||
|
headers = params.get("request", {}).get("headers", {})
|
||||||
|
auth = (headers.get("authorization") or headers.get("Authorization"))
|
||||||
|
api_key = (headers.get("x-api-key") or headers.get("X-API-Key"))
|
||||||
|
if auth:
|
||||||
|
captured.append({
|
||||||
|
"type": "authorization",
|
||||||
|
"value": auth,
|
||||||
|
"url": params.get("request", {}).get("url", ""),
|
||||||
|
})
|
||||||
|
logger.debug("auth-capture CDP: captured Authorization header")
|
||||||
|
if api_key:
|
||||||
|
captured.append({
|
||||||
|
"type": "api_key",
|
||||||
|
"value": api_key,
|
||||||
|
"url": params.get("request", {}).get("url", ""),
|
||||||
|
})
|
||||||
|
logger.debug("auth-capture CDP: captured X-API-Key header")
|
||||||
|
|
||||||
|
cdp.on("Network.requestWillBeSent", on_request)
|
||||||
|
# Give a moment for any in-flight requests to be captured
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("CDP network capture not available: %s", exc)
|
||||||
|
finally:
|
||||||
|
if cdp:
|
||||||
|
try:
|
||||||
|
await cdp.detach()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
async def extract_all(session: Any) -> dict[str, Any]:
|
async def extract_all(session: Any) -> dict[str, Any]:
|
||||||
"""Extract all possible auth credentials from a browser session.
|
"""Extract all auth credentials from a browser session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- cookies: list of cookie dicts
|
cookies, storage, session_storage, auth_headers, candidates
|
||||||
- storage: dict of localStorage key-values
|
|
||||||
- session_storage: dict of sessionStorage key-values
|
|
||||||
- candidates: curated list of likely auth tokens/credentials
|
|
||||||
"""
|
"""
|
||||||
page = session.page
|
page = session.page
|
||||||
cookies = await extract_cookies(session)
|
cookies = await extract_cookies(session)
|
||||||
local_storage = await extract_local_storage(page)
|
local_storage = await extract_local_storage(page)
|
||||||
session_storage = await extract_session_storage(page)
|
session_storage = await extract_session_storage(page)
|
||||||
candidates = _curate_candidates(cookies, local_storage, session_storage)
|
auth_headers = await extract_request_headers(page)
|
||||||
|
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cookies": cookies,
|
"cookies": cookies,
|
||||||
"storage": local_storage,
|
"storage": local_storage,
|
||||||
"session_storage": session_storage,
|
"session_storage": session_storage,
|
||||||
|
"auth_headers": auth_headers,
|
||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,68 +132,99 @@ def _curate_candidates(
|
|||||||
cookies: list[dict[str, Any]],
|
cookies: list[dict[str, Any]],
|
||||||
local_storage: dict[str, str],
|
local_storage: dict[str, str],
|
||||||
session_storage: dict[str, str],
|
session_storage: dict[str, str],
|
||||||
|
auth_headers: list[dict[str, str]],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Scan extracted data for likely bearer tokens and session cookies."""
|
"""Scan extracted data for likely credentials with confidence scoring."""
|
||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# 1. localStorage / sessionStorage items that look like tokens
|
# 1. CDP-captured Authorization headers (highest confidence)
|
||||||
|
seen = set()
|
||||||
|
for h in auth_headers:
|
||||||
|
dedup_key = h["value"]
|
||||||
|
if dedup_key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dedup_key)
|
||||||
|
preview = _preview(h["value"])
|
||||||
|
candidates.append({
|
||||||
|
"type": "bearer_token",
|
||||||
|
"source": f"network:{h['url'][:60]}",
|
||||||
|
"value": h["value"],
|
||||||
|
"preview": preview,
|
||||||
|
"label": f"Authorization — {h['url'][:40]}",
|
||||||
|
"confidence": 95,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. localStorage/sessionStorage items
|
||||||
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
||||||
for key, val in store.items():
|
for key, val in store.items():
|
||||||
if not isinstance(val, str) or not val:
|
if not isinstance(val, str) or not val:
|
||||||
continue
|
continue
|
||||||
key_lower = key.lower()
|
key_lower = key.lower()
|
||||||
|
|
||||||
# Explicit auth keys
|
# Explicit auth-named keys
|
||||||
if any(k in key_lower for k in ("token", "jwt", "auth", "access", "secret", "api_key")):
|
if any(k in key_lower for k in TOKEN_KEYS):
|
||||||
_add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val,
|
preview = _preview(val)
|
||||||
f"{store_name}.{key}")
|
score = 85 if "token" in key_lower and val.count(".") >= 2 else 75
|
||||||
# JWT-shaped strings (not in an auth-named key)
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, preview,
|
||||||
elif val.count(".") >= 2 and 20 < len(val) < 5000:
|
f"{store_name}.{key}", score)
|
||||||
_add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val,
|
elif any(k in key_lower for k in SECRET_KEYS):
|
||||||
f"{store_name}.{key} (JWT)")
|
_add(candidates, "credential", f"{store_name}.{key}", val, _preview(val),
|
||||||
|
f"{store_name}.{key}", 70)
|
||||||
|
|
||||||
# 2. Cookies that look like session/token cookies
|
# Looks like a JWT (xx.yy.zz format)
|
||||||
cookie_keywords = ("session", "token", "jwt", "sid", "auth", "connect.sid", "gin_session", "tdc_itoken")
|
if val.count(".") >= 2 and 20 < len(val) < 5000:
|
||||||
|
if dedup_key := val not in seen:
|
||||||
|
seen.add(val)
|
||||||
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
||||||
|
f"{store_name}.{key} (JWT)", 80)
|
||||||
|
|
||||||
|
# sk-xxx API key pattern
|
||||||
|
if val.startswith("sk-") and len(val) > 10:
|
||||||
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
||||||
|
f"{store_name}.{key} (API Key)", 90)
|
||||||
|
|
||||||
|
# 3. Session cookies
|
||||||
for c in cookies:
|
for c in cookies:
|
||||||
cname = c["name"].lower()
|
cname = c["name"].lower()
|
||||||
if any(k in cname for k in cookie_keywords):
|
if any(k in cname for k in SESSION_COOKIE_NAMES):
|
||||||
_add_candidate(candidates, "cookie", f"cookie:{c['name']}", f"{c['name']}={c['value']}",
|
preview = _preview(c["value"])
|
||||||
f"🍪 {c['name']} ({c['domain']})",
|
cookie_val = f"{c['name']}={c['value']}"
|
||||||
extra={"cookie_name": c["name"], "cookie_value": c["value"]})
|
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
|
||||||
|
f"🍪 {c['name']} ({c['domain']})", 75,
|
||||||
|
extra={"cookie_name": c["name"], "cookie_value": c["value"]})
|
||||||
|
|
||||||
# 3. Any localStorage key whose value looks like a sk-xxx key
|
return candidates
|
||||||
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
|
||||||
for key, val in store.items():
|
|
||||||
if isinstance(val, str) and val.startswith("sk-") and len(val) > 10:
|
|
||||||
_add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val,
|
|
||||||
f"{store_name}.{key} (sk-key)")
|
|
||||||
|
|
||||||
# Deduplicate by value
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for c in candidates:
|
|
||||||
if c["value"] not in seen:
|
|
||||||
seen.add(c["value"])
|
|
||||||
deduped.append(c)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
|
|
||||||
def _add_candidate(
|
def _add(
|
||||||
candidates: list[dict[str, Any]],
|
candidates: list[dict[str, Any]],
|
||||||
ctype: str,
|
ctype: str,
|
||||||
source: str,
|
source: str,
|
||||||
value: str,
|
value: str,
|
||||||
|
preview: str,
|
||||||
label: str,
|
label: str,
|
||||||
|
confidence: int,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a candidate, masking sensitive values in logs."""
|
"""Add a candidate entry. Value is masked in logs."""
|
||||||
logger.debug("auth-capture candidate: type=%s source=%s label=%s", ctype, source, label)
|
logger.debug("auth-capture candidate: type=%s source=%s confidence=%d", ctype, source, confidence)
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"type": ctype,
|
"type": ctype,
|
||||||
"source": source,
|
"source": source,
|
||||||
"value": value,
|
"value": value,
|
||||||
|
"preview": preview,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"confidence": confidence,
|
||||||
}
|
}
|
||||||
if extra:
|
if extra:
|
||||||
entry.update(extra)
|
entry.update(extra)
|
||||||
candidates.append(entry)
|
candidates.append(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview(value: str) -> str:
|
||||||
|
"""Generate a masked preview of a credential."""
|
||||||
|
if not value or len(value) <= 8:
|
||||||
|
return "***"
|
||||||
|
if len(value) <= 16:
|
||||||
|
return value[:4] + "…" + value[-4:]
|
||||||
|
return value[:8] + "…" + value[-6:]
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ class UpstreamClient:
|
|||||||
header = self.auth_config.get("header", "Authorization")
|
header = self.auth_config.get("header", "Authorization")
|
||||||
if key:
|
if key:
|
||||||
headers[header] = key
|
headers[header] = key
|
||||||
|
elif self.auth_type == "cookie":
|
||||||
|
cookie_str = self.auth_config.get("cookie_string", "")
|
||||||
|
if cookie_str:
|
||||||
|
headers["Cookie"] = cookie_str
|
||||||
elif self.auth_type == "login_password" and self._token:
|
elif self.auth_type == "login_password" and self._token:
|
||||||
headers["Authorization"] = f"Bearer {self._token}"
|
headers["Authorization"] = f"Bearer {self._token}"
|
||||||
if self.auth_type == "login_password" and self._new_api_user:
|
if self.auth_type == "login_password" and self._new_api_user:
|
||||||
|
|||||||
@@ -337,11 +337,14 @@ export interface AuthCaptureResult {
|
|||||||
cookies: Record<string, any>[]
|
cookies: Record<string, any>[]
|
||||||
storage: Record<string, string>
|
storage: Record<string, string>
|
||||||
session_storage: Record<string, string>
|
session_storage: Record<string, string>
|
||||||
|
auth_headers: Record<string, string>[]
|
||||||
candidates: {
|
candidates: {
|
||||||
type: 'bearer_token' | 'cookie' | 'credential'
|
type: 'bearer_token' | 'cookie' | 'credential'
|
||||||
source: string
|
source: string
|
||||||
value: string
|
value: string
|
||||||
|
preview: string
|
||||||
label: string
|
label: string
|
||||||
|
confidence: number
|
||||||
cookie_name?: string
|
cookie_name?: string
|
||||||
cookie_value?: string
|
cookie_value?: string
|
||||||
}[]
|
}[]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="🔐 远程浏览器认证提取"
|
title="🔐 远程浏览器认证提取"
|
||||||
width="900px"
|
width="960px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:before-close="handleClose"
|
:before-close="handleClose"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@@ -15,73 +15,138 @@
|
|||||||
<el-form-item label="登录页 URL">
|
<el-form-item label="登录页 URL">
|
||||||
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
|
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button type="primary" :loading="launching" @click="launchBrowser">
|
<div class="capture-extra-fields" v-if="showExtraFields">
|
||||||
<el-icon><Pointer /></el-icon>
|
<el-form-item label="登录账号">
|
||||||
打开远程浏览器
|
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
|
||||||
</el-button>
|
</el-form-item>
|
||||||
|
<el-form-item label="登录密码">
|
||||||
|
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户名选择器">
|
||||||
|
<el-input v-model="usernameSelector" placeholder="input[name=email]" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码选择器">
|
||||||
|
<el-input v-model="passwordSelector" placeholder="input[name=password]" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提交按钮选择器">
|
||||||
|
<el-input v-model="submitSelector" placeholder="button[type=submit]" />
|
||||||
|
</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>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Browser viewer + manual login -->
|
<!-- Step 2: Interactive browser + manual login -->
|
||||||
<div v-else-if="sessionId && !extracted" class="capture-step">
|
<div v-else class="capture-step">
|
||||||
<div class="capture-step-header">
|
<div class="capture-step-header">
|
||||||
<h4>步骤 2:在浏览器中手动登录</h4>
|
<h4>步骤 2:在浏览器中手动登录</h4>
|
||||||
<div class="capture-actions">
|
<div class="capture-actions">
|
||||||
|
<el-button size="small" @click="sendEvent('back')" :disabled="!sessionId">
|
||||||
|
<el-icon><Back /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="sendEvent('forward')" :disabled="!sessionId">
|
||||||
|
<el-icon><Right /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="sendEvent('reload')" :disabled="!sessionId">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
<el-button size="small" :loading="extracting" type="primary" @click="extractCredentials">
|
<el-button size="small" :loading="extracting" type="primary" @click="extractCredentials">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
提取认证信息
|
提取认证信息
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="capture-hint">完成登录后,点击「提取认证信息」获取 token / cookie。</p>
|
<p class="capture-hint">在下方浏览器中完成登录后,点击「提取认证信息」获取 token / cookie。</p>
|
||||||
<div class="browser-viewport">
|
|
||||||
<img :src="screenshotUrl" alt="browser preview" class="browser-frame" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: Results -->
|
<!-- Interactive browser frame -->
|
||||||
<div v-else class="capture-step">
|
<div
|
||||||
<h4>提取结果</h4>
|
ref="browserFrameRef"
|
||||||
<p class="capture-hint" v-if="result && result.candidates.length === 0">
|
class="browser-viewport"
|
||||||
未找到认证凭据。请确认已成功登录,或重新尝试。
|
tabindex="0"
|
||||||
</p>
|
@keydown.prevent="onKeydown"
|
||||||
|
@wheel.prevent="onWheel"
|
||||||
<div v-if="result" class="candidate-list">
|
@pointerdown.stop.prevent="onPointerDown"
|
||||||
<div
|
@pointermove.stop.prevent="onPointerMove"
|
||||||
v-for="(c, i) in result.candidates"
|
@pointerup.stop.prevent="onPointerUp"
|
||||||
:key="i"
|
@pointercancel.stop.prevent="onPointerCancel"
|
||||||
class="candidate-card"
|
@dblclick.prevent="onDblClick"
|
||||||
:class="{ selected: selectedIndex === i }"
|
@contextmenu.prevent
|
||||||
@click="selectedIndex = i"
|
>
|
||||||
>
|
<img
|
||||||
<div class="candidate-radio">
|
v-if="screenshotUrl"
|
||||||
<el-radio :model-value="selectedIndex === i" :label="i" @click="selectedIndex = i">
|
:src="screenshotUrl"
|
||||||
<span class="candidate-type-badge" :class="c.type">{{ c.type === 'bearer_token' ? 'Bearer' : 'Cookie' }}</span>
|
class="browser-frame"
|
||||||
<span class="candidate-label">{{ c.label }}</span>
|
alt="远程浏览器"
|
||||||
</el-radio>
|
draggable="false"
|
||||||
</div>
|
@load="onScreenshotLoaded"
|
||||||
<div class="candidate-value">
|
@error="onScreenshotError"
|
||||||
<code>{{ maskValue(c.value) }}</code>
|
/>
|
||||||
</div>
|
<div v-else class="browser-loading">
|
||||||
|
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
|
<p>正在启动浏览器…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="capture-step-footer">
|
<!-- Extraction results (overlay after extract) -->
|
||||||
<el-button @click="resetSession">重新提取</el-button>
|
<transition name="el-zoom-in-top">
|
||||||
<el-button type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
|
<div v-if="extracted && result" class="candidate-panel">
|
||||||
填入当前表单
|
<div class="candidate-panel-header">
|
||||||
</el-button>
|
<span>提取到 {{ result.candidates.length }} 个认证凭据</span>
|
||||||
</div>
|
<el-button size="small" text @click="resetExtract">重新提取</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="result.candidates.length === 0" class="candidate-empty">
|
||||||
|
未找到认证凭据。请确认已成功登录后重试。
|
||||||
|
</div>
|
||||||
|
<div v-else class="candidate-list">
|
||||||
|
<div
|
||||||
|
v-for="(c, i) in result.candidates"
|
||||||
|
:key="i"
|
||||||
|
class="candidate-card"
|
||||||
|
:class="{ selected: selectedIndex === i }"
|
||||||
|
@click="selectedIndex = i"
|
||||||
|
>
|
||||||
|
<div class="candidate-row">
|
||||||
|
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = i">
|
||||||
|
<span class="candidate-badge" :class="c.type || 'credential'">
|
||||||
|
{{ c.type === 'bearer_token' ? 'Bearer' : c.type === 'cookie' ? 'Cookie' : 'Key' }}
|
||||||
|
</span>
|
||||||
|
<span class="candidate-label">{{ c.label }}</span>
|
||||||
|
</el-radio>
|
||||||
|
<span v-if="c.confidence" class="candidate-confidence" :class="confidenceClass(c.confidence)">
|
||||||
|
{{ c.confidence }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-preview">
|
||||||
|
<code>{{ maskValue(c.preview || c.value) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-actions">
|
||||||
|
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||||
|
<el-button size="small" type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
|
||||||
|
填入当前表单
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { Pointer, Search } from '@element-plus/icons-vue'
|
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
|
||||||
import { authCaptureApi, type AuthCaptureResult } from '@/api'
|
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -95,13 +160,21 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const visible = ref(props.modelValue)
|
const visible = ref(props.modelValue)
|
||||||
watch(() => props.modelValue, (v) => { visible.value = v })
|
watch(() => props.modelValue, (v) => { visible.value = v })
|
||||||
|
|
||||||
const targetUrl = ref(props.initialUrl || '')
|
const targetUrl = ref(props.initialUrl || '')
|
||||||
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
||||||
|
|
||||||
|
// Auto-fill fields
|
||||||
|
const showExtraFields = ref(false)
|
||||||
|
const loginUsername = ref('')
|
||||||
|
const loginPassword = ref('')
|
||||||
|
const usernameSelector = ref('input[name="email"],input[name="username"],input[type="email"]')
|
||||||
|
const passwordSelector = ref('input[name="password"],input[type="password"]')
|
||||||
|
const submitSelector = ref('button[type="submit"],input[type="submit"]')
|
||||||
|
|
||||||
|
// Session
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
const extracting = ref(false)
|
const extracting = ref(false)
|
||||||
@@ -109,6 +182,11 @@ const extracted = ref(false)
|
|||||||
const result = ref<AuthCaptureResult | null>(null)
|
const result = ref<AuthCaptureResult | null>(null)
|
||||||
const selectedIndex = ref(-1)
|
const selectedIndex = ref(-1)
|
||||||
const screenshotUrl = ref('')
|
const screenshotUrl = ref('')
|
||||||
|
const browserFrameRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Pointer tracking for mouse events
|
||||||
|
const pointerPos = ref({ x: 0, y: 0 })
|
||||||
|
const imgNaturalSize = ref({ w: 0, h: 0 })
|
||||||
|
|
||||||
async function launchBrowser() {
|
async function launchBrowser() {
|
||||||
if (!targetUrl.value) return
|
if (!targetUrl.value) return
|
||||||
@@ -116,17 +194,121 @@ async function launchBrowser() {
|
|||||||
try {
|
try {
|
||||||
const res = await authCaptureApi.createSession(targetUrl.value)
|
const res = await authCaptureApi.createSession(targetUrl.value)
|
||||||
sessionId.value = res.data.session_id
|
sessionId.value = res.data.session_id
|
||||||
// Build screenshot URL with auth token
|
screenshotUrl.value = buildScreenshotUrl()
|
||||||
const token = auth.token
|
|
||||||
screenshotUrl.value = `/api/browser-sessions/${sessionId.value}/screenshot?token=${token}&t=${Date.now()}`
|
// Auto-fill login form after page loads
|
||||||
|
if (loginUsername.value || loginPassword.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loginUsername.value) {
|
||||||
|
browserSessionsApi.event(sessionId.value, {
|
||||||
|
type: 'type',
|
||||||
|
text: loginUsername.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Focus password field via tab
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loginPassword.value) {
|
||||||
|
browserSessionsApi.event(sessionId.value, {
|
||||||
|
type: 'type',
|
||||||
|
text: loginPassword.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('launch failed', e)
|
console.error('launch failed', e)
|
||||||
// fallback
|
|
||||||
} finally {
|
} finally {
|
||||||
launching.value = false
|
launching.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildScreenshotUrl() {
|
||||||
|
const token = auth.token
|
||||||
|
return browserSessionsApi.screenshotUrl(sessionId.value, token) + '&t=' + Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScreenshot() {
|
||||||
|
screenshotUrl.value = buildScreenshotUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenshotLoading = false
|
||||||
|
function onScreenshotLoaded() {
|
||||||
|
screenshotLoading = false
|
||||||
|
// Trigger next refresh after a short interval if still in browser view
|
||||||
|
if (sessionId.value && !extracted.value) {
|
||||||
|
setTimeout(refreshScreenshot, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onScreenshotError() {
|
||||||
|
screenshotLoading = false
|
||||||
|
if (sessionId.value && !extracted.value) {
|
||||||
|
setTimeout(refreshScreenshot, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Event forwarding ———
|
||||||
|
|
||||||
|
async function sendEvent(type: string, extra: Record<string, any> = {}) {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
try {
|
||||||
|
const eventData: any = { type, ...extra }
|
||||||
|
await browserSessionsApi.event(sessionId.value, eventData)
|
||||||
|
refreshScreenshot()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('event failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalePointer(e: PointerEvent) {
|
||||||
|
const el = browserFrameRef.value
|
||||||
|
if (!el) return { x: e.clientX, y: e.clientY }
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
// Scale from display size to natural image size
|
||||||
|
const scaleX = imgNaturalSize.value.w / rect.width
|
||||||
|
const scaleY = imgNaturalSize.value.h / rect.height
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * scaleX,
|
||||||
|
y: (e.clientY - rect.top) * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const p = scalePointer(e)
|
||||||
|
pointerPos.value = p
|
||||||
|
sendEvent('click', { x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
||||||
|
}
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
const p = scalePointer(e)
|
||||||
|
pointerPos.value = p
|
||||||
|
}
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
pointerPos.value = scalePointer(e)
|
||||||
|
}
|
||||||
|
function onPointerCancel() { /* no-op */ }
|
||||||
|
function onDblClick(e: MouseEvent) {
|
||||||
|
const p = scalePointer(e as unknown as PointerEvent)
|
||||||
|
sendEvent('dblclick', { x: p.x, y: p.y })
|
||||||
|
}
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
sendEvent('scroll', { delta_x: e.deltaX, delta_y: e.deltaY, x: pointerPos.value.x, y: pointerPos.value.y })
|
||||||
|
}
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendEvent('key', { key: 'Enter' })
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
sendEvent('key', { key: 'Backspace' })
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
sendEvent('key', { key: 'Escape' })
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
sendEvent('key', { key: 'Tab' })
|
||||||
|
} else if (e.key.length === 1) {
|
||||||
|
sendEvent('type', { text: e.key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Extraction ———
|
||||||
|
|
||||||
async function extractCredentials() {
|
async function extractCredentials() {
|
||||||
if (!sessionId.value) return
|
if (!sessionId.value) return
|
||||||
extracting.value = true
|
extracting.value = true
|
||||||
@@ -155,11 +337,11 @@ function confirmSelection() {
|
|||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSession() {
|
function resetExtract() {
|
||||||
extracted.value = false
|
extracted.value = false
|
||||||
result.value = null
|
result.value = null
|
||||||
selectedIndex.value = -1
|
selectedIndex.value = -1
|
||||||
// Keep existing session — user can try extracting again
|
refreshScreenshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClose() {
|
async function handleClose() {
|
||||||
@@ -175,98 +357,167 @@ function closeDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maskValue(v: string): string {
|
function maskValue(v: string): string {
|
||||||
if (v.length <= 8) return '***'
|
if (!v || v.length <= 8) return '***'
|
||||||
return v.slice(0, 6) + '…' + v.slice(-4)
|
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
|
||||||
|
return v.slice(0, 8) + '…' + v.slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceClass(score: number): string {
|
||||||
|
if (score >= 80) return 'conf-high'
|
||||||
|
if (score >= 50) return 'conf-mid'
|
||||||
|
return 'conf-low'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-capture-body {
|
.auth-capture-body {
|
||||||
min-height: 300px;
|
min-height: 350px;
|
||||||
}
|
}
|
||||||
.capture-step {
|
.capture-step {
|
||||||
padding: 8px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.capture-step-header {
|
.capture-step-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.capture-step-header h4 {
|
.capture-step-header h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.capture-actions {
|
.capture-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.capture-hint {
|
.capture-hint {
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 4px 0 12px;
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.capture-extra-fields {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.capture-launch-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.browser-viewport {
|
.browser-viewport {
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
|
cursor: crosshair;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
.browser-frame {
|
.browser-frame {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 480px;
|
user-select: none;
|
||||||
object-fit: contain;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
.candidate-list {
|
.browser-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
margin: 12px 0;
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.candidate-card {
|
|
||||||
|
/* Candidate panel */
|
||||||
|
.candidate-panel {
|
||||||
|
margin-top: 12px;
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.candidate-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.candidate-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.candidate-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.candidate-card {
|
||||||
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.candidate-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.candidate-card:hover,
|
.candidate-card:hover,
|
||||||
.candidate-card.selected {
|
.candidate-card.selected {
|
||||||
border-color: var(--el-color-primary);
|
background: var(--el-color-primary-light-9);
|
||||||
}
|
}
|
||||||
.candidate-radio {
|
.candidate-row {
|
||||||
margin-bottom: 4px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.candidate-type-badge {
|
.candidate-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
.candidate-type-badge.bearer_token {
|
.candidate-badge.bearer_token { background: #e6f7ff; color: #1890ff; }
|
||||||
background: #e6f7ff;
|
.candidate-badge.cookie { background: #fff7e6; color: #d48806; }
|
||||||
color: #1890ff;
|
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
|
||||||
}
|
|
||||||
.candidate-type-badge.cookie {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #d48806;
|
|
||||||
}
|
|
||||||
.candidate-label {
|
.candidate-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
.candidate-value code {
|
.candidate-confidence {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.conf-high { background: #f6ffed; color: #52c41a; }
|
||||||
|
.conf-mid { background: #fff7e6; color: #d48806; }
|
||||||
|
.conf-low { background: #fff2f0; color: #ff4d4f; }
|
||||||
|
.candidate-preview {
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
.candidate-preview code {
|
||||||
|
font-size: 0.78rem;
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.capture-step-footer {
|
.candidate-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
padding: 8px 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
<el-select v-model="form.auth_type" style="width: 100%">
|
<el-select v-model="form.auth_type" style="width: 100%">
|
||||||
<el-option label="无认证" value="none" />
|
<el-option label="无认证" value="none" />
|
||||||
<el-option label="Bearer Token" value="bearer" />
|
<el-option label="Bearer Token" value="bearer" />
|
||||||
|
<el-option label="Cookie" value="cookie" />
|
||||||
<el-option label="API Key" value="api_key" />
|
<el-option label="API Key" value="api_key" />
|
||||||
<el-option label="邮箱密码登录" value="login_password" />
|
<el-option label="邮箱密码登录" value="login_password" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -249,6 +250,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="form.auth_type === 'cookie'">
|
||||||
|
<el-form-item label="Cookie">
|
||||||
|
<div class="auth-field-row">
|
||||||
|
<el-input v-model="form.auth_config.cookie_string" type="password" show-password placeholder="name=value; name2=value2" />
|
||||||
|
<el-button size="small" @click="openAuthCapture">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
提取
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'api_key'">
|
<template v-else-if="form.auth_type === 'api_key'">
|
||||||
<el-form-item label="API Key">
|
<el-form-item label="API Key">
|
||||||
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
|
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
|
||||||
@@ -446,12 +458,15 @@ function openAuthCapture() {
|
|||||||
|
|
||||||
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
|
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
|
||||||
if (candidate.type === 'bearer_token') {
|
if (candidate.type === 'bearer_token') {
|
||||||
|
form.value.auth_type = 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
} else if (candidate.type === 'cookie') {
|
} else if (candidate.type === 'cookie') {
|
||||||
// For cookie auth, store as a formatted cookie string
|
form.value.auth_type = 'cookie'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
||||||
|
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
||||||
|
: candidate.value
|
||||||
}
|
}
|
||||||
ElMessage.success('已填入认证信息')
|
ElMessage.success(`已填入${candidate.type === 'cookie' ? 'Cookie' : 'Bearer Token'}认证信息`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickPlatform = ref('sub2api')
|
const quickPlatform = ref('sub2api')
|
||||||
@@ -521,7 +536,7 @@ const recentChecks = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
|
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
|
||||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
||||||
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
|||||||
Reference in New Issue
Block a user