feat: remote browser login persistence + balance display + UI consistency
- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
This commit is contained in:
@@ -21,3 +21,7 @@ backend/data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.git-real/
|
||||
|
||||
# 外部项目(不提交)
|
||||
/sub2api/
|
||||
/new-api/
|
||||
|
||||
@@ -29,6 +29,7 @@ def init_db():
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_custom_pages()
|
||||
_migrate_upstreams()
|
||||
|
||||
|
||||
def _migrate_custom_pages():
|
||||
@@ -67,3 +68,22 @@ def _migrate_custom_pages():
|
||||
if "linked_upstream_id" not in columns:
|
||||
conn.execute(text("ALTER TABLE custom_pages ADD COLUMN linked_upstream_id INTEGER"))
|
||||
|
||||
|
||||
def _migrate_upstreams():
|
||||
"""Apply SQLite-safe migrations to the upstreams table."""
|
||||
inspector = inspect(engine)
|
||||
if "upstreams" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {col["name"] for col in inspector.get_columns("upstreams")}
|
||||
with engine.begin() as conn:
|
||||
if "balance" not in columns:
|
||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance FLOAT"))
|
||||
if "balance_updated_at" not in columns:
|
||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_updated_at DATETIME"))
|
||||
if "balance_endpoint" not in columns:
|
||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_endpoint VARCHAR(256) NOT NULL DEFAULT ''"))
|
||||
if "balance_response_path" not in columns:
|
||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_response_path VARCHAR(256) NOT NULL DEFAULT ''"))
|
||||
if "balance_divisor" not in columns:
|
||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_divisor FLOAT NOT NULL DEFAULT 1.0"))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text, Float
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
@@ -26,6 +26,12 @@ class Upstream(Base):
|
||||
last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Balance tracking
|
||||
balance: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
balance_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
balance_endpoint: Mapped[str] = mapped_column(String(256), default="")
|
||||
balance_response_path: Mapped[str] = mapped_column(String(256), default="")
|
||||
balance_divisor: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
|
||||
@@ -131,11 +131,113 @@ async def get_selection(session_id: str, _=Depends(get_current_user)):
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
class BrowserClipboardResponse(BaseModel):
|
||||
text: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{session_id}/clipboard", response_model=BrowserClipboardResponse)
|
||||
async def session_clipboard(session_id: str, _=Depends(get_current_user)):
|
||||
"""Read text from the remote browser's clipboard."""
|
||||
from fastapi.responses import JSONResponse
|
||||
try:
|
||||
text, error = await browser_sessions.read_clipboard(session_id)
|
||||
body: dict[str, Any] = {}
|
||||
if text:
|
||||
body["text"] = text
|
||||
elif error == "denied":
|
||||
body["error"] = "远程浏览器未授予剪贴板读取权限"
|
||||
elif error == "read_failed":
|
||||
body["error"] = "读取远程剪贴板时发生内部错误"
|
||||
else:
|
||||
if error:
|
||||
logger.warning("clipboard read error for %s: %s", session_id[:12], error)
|
||||
body["error"] = "远程剪贴板为空"
|
||||
return JSONResponse(content=body, headers={"Cache-Control": "no-store"})
|
||||
except Exception as exc:
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
class AutofillLoginResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/{session_id}/autofill-login", response_model=AutofillLoginResponse)
|
||||
async def autofill_login(session_id: str, _=Depends(get_current_user)):
|
||||
"""Manually trigger login autofill for the remote browser session.
|
||||
|
||||
Uses the linked custom page's saved credentials. Never returns passwords.
|
||||
"""
|
||||
try:
|
||||
session_state = await browser_sessions.state(session_id)
|
||||
except Exception as exc:
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
from app.database import SessionLocal as _Db
|
||||
from app.models.custom_page import CustomPage
|
||||
db = _Db()
|
||||
try:
|
||||
page = db.query(CustomPage).filter(
|
||||
CustomPage.id == session_state["custom_page_id"]
|
||||
).first()
|
||||
if not page or not page.enabled:
|
||||
raise HTTPException(400, "linked custom page is not available")
|
||||
if page.access_mode != "remote_browser":
|
||||
raise HTTPException(400, "linked custom page is not in remote browser mode")
|
||||
if not page.login_autofill_enabled:
|
||||
return AutofillLoginResponse(success=False, message="该页面未启用自动填充登录")
|
||||
if not page.login_username or not page.login_password:
|
||||
return AutofillLoginResponse(success=False, message="该页面未保存账号密码")
|
||||
|
||||
login_config = {
|
||||
"enabled": True,
|
||||
"username": page.login_username,
|
||||
"password": page.login_password,
|
||||
"username_selector": page.login_username_selector,
|
||||
"password_selector": page.login_password_selector,
|
||||
"submit_selector": page.login_submit_selector,
|
||||
}
|
||||
|
||||
filled = await browser_sessions.autofill_login(session_id, login_config)
|
||||
if filled:
|
||||
return AutofillLoginResponse(success=True, message="已填入账号密码")
|
||||
return AutofillLoginResponse(
|
||||
success=False,
|
||||
message="未找到登录输入框,请先关闭弹窗或进入登录页后重试",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=204)
|
||||
async def close_session(session_id: str, _=Depends(get_current_user)):
|
||||
await browser_sessions.close(session_id)
|
||||
|
||||
|
||||
@router.delete("/profiles/{custom_page_id}", status_code=204)
|
||||
async def clear_profile(custom_page_id: int, _=Depends(get_current_user)):
|
||||
"""Close active session for the page and delete its profile directory.
|
||||
|
||||
On next open the browser starts fresh, losing login state.
|
||||
"""
|
||||
from app.models.custom_page import CustomPage
|
||||
from app.database import SessionLocal as _Db
|
||||
db = _Db()
|
||||
try:
|
||||
page = db.query(CustomPage).filter(CustomPage.id == custom_page_id).first()
|
||||
if not page or not page.enabled:
|
||||
raise HTTPException(404, "custom page not found")
|
||||
if page.access_mode != "remote_browser":
|
||||
raise HTTPException(400, "custom page is not in remote browser mode")
|
||||
try:
|
||||
await browser_sessions.clear_profile(custom_page_id, page.url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ——— WebSocket stream ———
|
||||
# Frame interval & diff detection
|
||||
_WS_MIN_INTERVAL = 0.10
|
||||
|
||||
@@ -270,7 +270,20 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
|
||||
existing_config["new_api_user"] = candidate["new_api_user"]
|
||||
elif ctype == "bearer_token":
|
||||
upstream.auth_type = "bearer"
|
||||
existing_config["token"] = candidate.get("value", "")
|
||||
raw = candidate.get("value", "")
|
||||
# Clean up: strip whitespace, remove "Bearer " prefix if present
|
||||
token = raw.strip()
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:].strip()
|
||||
# Validate token can be used as HTTP header value
|
||||
try:
|
||||
token.encode("latin-1")
|
||||
except UnicodeEncodeError:
|
||||
return RefreshAuthResponse(
|
||||
success=False,
|
||||
message=f"提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
|
||||
)
|
||||
existing_config["token"] = token
|
||||
elif ctype == "api_key":
|
||||
upstream.auth_type = "api_key"
|
||||
existing_config["key"] = candidate.get("value", "")
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -55,6 +58,11 @@ def _to_response(u: Upstream) -> UpstreamResponse:
|
||||
last_status=u.last_status,
|
||||
last_checked_at=u.last_checked_at,
|
||||
last_error=u.last_error,
|
||||
balance=u.balance,
|
||||
balance_updated_at=u.balance_updated_at,
|
||||
balance_endpoint=u.balance_endpoint or "",
|
||||
balance_response_path=u.balance_response_path or "",
|
||||
balance_divisor=u.balance_divisor or 1.0,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
)
|
||||
@@ -82,6 +90,9 @@ def create_upstream(
|
||||
enabled=body.enabled,
|
||||
check_interval_seconds=body.check_interval_seconds,
|
||||
timeout_seconds=body.timeout_seconds,
|
||||
balance_endpoint=body.balance_endpoint,
|
||||
balance_response_path=body.balance_response_path,
|
||||
balance_divisor=body.balance_divisor,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
@@ -156,6 +167,16 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
# Also try balance if configured
|
||||
if u.balance_endpoint and u.balance_response_path:
|
||||
try:
|
||||
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
|
||||
if raw_balance is not None:
|
||||
divisor = u.balance_divisor or 1.0
|
||||
u.balance = raw_balance / divisor
|
||||
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
|
||||
except Exception as exc:
|
||||
logger.warning("upstream %s balance fetch failed during test: %s", u.name, exc)
|
||||
u.last_status = "healthy"
|
||||
u.last_error = None
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
@@ -189,6 +210,16 @@ def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_use
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(u.rate_endpoint)
|
||||
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
|
||||
# Also try balance if configured
|
||||
if u.balance_endpoint and u.balance_response_path:
|
||||
try:
|
||||
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
|
||||
if raw_balance is not None:
|
||||
divisor = u.balance_divisor or 1.0
|
||||
u.balance = raw_balance / divisor
|
||||
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
|
||||
except Exception as exc:
|
||||
logger.warning("upstream %s balance fetch failed during check-now: %s", u.name, exc)
|
||||
except Exception as exc:
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
u.last_error = str(exc)
|
||||
|
||||
@@ -29,6 +29,9 @@ class UpstreamCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
check_interval_seconds: int = 600
|
||||
timeout_seconds: int = 30
|
||||
balance_endpoint: str = ""
|
||||
balance_response_path: str = ""
|
||||
balance_divisor: float = 1.0
|
||||
|
||||
|
||||
class UpstreamUpdate(BaseModel):
|
||||
@@ -42,6 +45,9 @@ class UpstreamUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
check_interval_seconds: Optional[int] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
balance_endpoint: Optional[str] = None
|
||||
balance_response_path: Optional[str] = None
|
||||
balance_divisor: Optional[float] = None
|
||||
|
||||
|
||||
class UpstreamResponse(BaseModel):
|
||||
@@ -59,6 +65,11 @@ class UpstreamResponse(BaseModel):
|
||||
last_status: str
|
||||
last_checked_at: Optional[datetime]
|
||||
last_error: Optional[str]
|
||||
balance: Optional[float] = None
|
||||
balance_updated_at: Optional[datetime] = None
|
||||
balance_endpoint: str = ""
|
||||
balance_response_path: str = ""
|
||||
balance_divisor: float = 1.0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -37,11 +37,18 @@ class BrowserSession:
|
||||
|
||||
|
||||
class BrowserSessionService:
|
||||
# Idle TTL: close sessions that haven't had activity for this long
|
||||
IDLE_TTL_SECONDS = 1800 # 30 minutes
|
||||
# Cap: max concurrent persistent sessions (excludes auth-capture)
|
||||
MAX_SESSIONS = 10
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._playwright: Optional[Any] = None
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._profiles: dict[str, str] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_event_at: dict[str, float] = {}
|
||||
self._evict_task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -61,21 +68,43 @@ class BrowserSessionService:
|
||||
existing_id = self._profiles.get(profile_key)
|
||||
existing = self._sessions.get(existing_id or "")
|
||||
if existing and not existing.page.is_closed():
|
||||
# Health check: verify session can actually serve content
|
||||
healthy = True
|
||||
try:
|
||||
async with existing.lock:
|
||||
url_before = existing.page.url
|
||||
await existing.page.evaluate("1") # ping
|
||||
await existing.page.screenshot(type="jpeg", quality=10, timeout=5000)
|
||||
await existing.page.set_viewport_size({"width": width, "height": height})
|
||||
if existing.page.url == "about:blank":
|
||||
if url_before == "about:blank":
|
||||
await existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
await self._autofill_login(existing.page, login_config)
|
||||
await self._reset_page_zoom(existing)
|
||||
self._touch(existing.id)
|
||||
except Exception:
|
||||
logger.info("existing session %s unhealthy, recreating", existing.id[:12])
|
||||
healthy = False
|
||||
if healthy:
|
||||
return existing
|
||||
# Close unhealthy session (profile stays on disk)
|
||||
await self.close(existing.id)
|
||||
if existing_id:
|
||||
self._profiles.pop(profile_key, None)
|
||||
# Idle cleanup: close stale sessions before spawning new ones
|
||||
await self._evict_idle_sessions()
|
||||
context = await self._playwright.chromium.launch_persistent_context(
|
||||
str(self._profile_dir(profile_key)),
|
||||
headless=get_settings().browser_headless,
|
||||
viewport={"width": width, "height": height},
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
# Grant clipboard access for the page origin
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||
except Exception:
|
||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
session = BrowserSession(
|
||||
id=uuid4().hex,
|
||||
@@ -87,6 +116,9 @@ class BrowserSessionService:
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
self._profiles[profile_key] = session.id
|
||||
self._touch(session.id)
|
||||
# Evict again after adding the new session so cap is enforced immediately
|
||||
await self._evict_idle_sessions()
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
await self._autofill_login(page, login_config)
|
||||
@@ -96,8 +128,13 @@ class BrowserSessionService:
|
||||
raise
|
||||
return session
|
||||
|
||||
def _touch(self, session_id: str) -> None:
|
||||
"""Mark a session as recently active (reset idle timer)."""
|
||||
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
||||
|
||||
async def screenshot(self, session_id: str) -> bytes:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
|
||||
@@ -111,6 +148,7 @@ class BrowserSessionService:
|
||||
include_state: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
session = self._get(session_id)
|
||||
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
page = session.page
|
||||
@@ -156,12 +194,51 @@ class BrowserSessionService:
|
||||
|
||||
async def selected_text(self, session_id: str) -> str:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
|
||||
return str(value or "")
|
||||
|
||||
async def read_clipboard(self, session_id: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Read the remote browser's clipboard text.
|
||||
|
||||
Returns (text, error_reason).
|
||||
text is None when the clipboard is empty or unreadable.
|
||||
error_reason is None on success or "empty" — non-None indicates a genuine failure.
|
||||
"""
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
try:
|
||||
result = await session.page.evaluate("""
|
||||
async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return text || null;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException) {
|
||||
if (e.name === 'NotAllowedError') return 'ERROR:denied';
|
||||
if (e.name === 'NotFoundError') return null;
|
||||
}
|
||||
return 'ERROR:' + (e.message || String(e));
|
||||
}
|
||||
}
|
||||
""")
|
||||
if result is None:
|
||||
return None, None # empty clipboard
|
||||
if isinstance(result, str) and result.startswith("ERROR:"):
|
||||
reason = result[6:]
|
||||
logger.debug("clipboard read error for %s: %s", session_id[:12], reason)
|
||||
return None, reason
|
||||
return str(result), None
|
||||
except Exception as exc:
|
||||
logger.warning("clipboard read failed for %s: %s", session_id[:12], exc)
|
||||
return None, "read_failed"
|
||||
|
||||
async def close(self, session_id: str) -> None:
|
||||
self._last_event_at.pop(session_id, None)
|
||||
session = self._discard_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
@@ -185,6 +262,14 @@ class BrowserSessionService:
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
# Cancel the background eviction loop
|
||||
if self._evict_task is not None and not self._evict_task.done():
|
||||
self._evict_task.cancel()
|
||||
try:
|
||||
await self._evict_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._evict_task = None
|
||||
sessions = list(self._sessions)
|
||||
for session_id in sessions:
|
||||
await self.close(session_id)
|
||||
@@ -194,6 +279,7 @@ class BrowserSessionService:
|
||||
|
||||
async def state(self, session_id: str) -> dict[str, Any]:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await self._session_state(session)
|
||||
@@ -217,6 +303,9 @@ class BrowserSessionService:
|
||||
self._playwright = await async_playwright().start()
|
||||
except Exception as exc:
|
||||
raise BrowserDependencyError(f"Unable to start Playwright: {exc}") from exc
|
||||
# Start background eviction loop
|
||||
if self._evict_task is None or self._evict_task.done():
|
||||
self._evict_task = asyncio.create_task(self._evict_loop())
|
||||
|
||||
async def _reset_page_zoom(self, session: BrowserSession) -> None:
|
||||
try:
|
||||
@@ -228,20 +317,38 @@ class BrowserSessionService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def autofill_login(
|
||||
self,
|
||||
session_id: str,
|
||||
login_config: Optional[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Public: manually trigger login autofill for an active session.
|
||||
|
||||
Only fills username/password fields — never auto-submits.
|
||||
Returns True if fields were found and filled, False otherwise.
|
||||
Never returns password data to the caller.
|
||||
"""
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await self._autofill_login(session.page, login_config, max_wait_seconds=3.0, skip_submit=True)
|
||||
|
||||
async def _autofill_login(
|
||||
self,
|
||||
page: Any,
|
||||
config: Optional[dict[str, Any]],
|
||||
*,
|
||||
max_wait_seconds: float = 8.0,
|
||||
max_wait_seconds: float = 2.0,
|
||||
poll_interval_seconds: float = 0.25,
|
||||
) -> None:
|
||||
skip_submit: bool = False,
|
||||
) -> bool:
|
||||
if not config or not config.get("enabled"):
|
||||
return
|
||||
return False
|
||||
username = str(config.get("username") or "")
|
||||
password = str(config.get("password") or "")
|
||||
if not username or not password:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
username_selectors = [
|
||||
config.get("username_selector"),
|
||||
@@ -268,17 +375,20 @@ class BrowserSessionService:
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
)
|
||||
if not username_locator or not password_locator:
|
||||
logger.info("Login autofill skipped for %s: login fields not found", page.url)
|
||||
return
|
||||
logger.info("Login autofill skipped: login fields not found")
|
||||
return False
|
||||
await username_locator.fill(username, timeout=3000)
|
||||
await password_locator.fill(password, timeout=3000)
|
||||
if not skip_submit:
|
||||
submit_selector = str(config.get("submit_selector") or "").strip()
|
||||
if submit_selector:
|
||||
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
|
||||
if submit:
|
||||
await submit.click(timeout=3000)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.info("Login autofill skipped for %s: %s", page.url, exc)
|
||||
logger.info("Login autofill skipped: %s", exc)
|
||||
return False
|
||||
|
||||
async def _wait_for_login_locators(
|
||||
self,
|
||||
@@ -345,6 +455,68 @@ class BrowserSessionService:
|
||||
self._profiles.pop(session.profile_key, None)
|
||||
return session
|
||||
|
||||
async def _evict_loop(self) -> None:
|
||||
"""Background loop that runs every 5 minutes to evict idle sessions."""
|
||||
while True:
|
||||
await asyncio.sleep(300) # 5 minutes
|
||||
try:
|
||||
await self._evict_idle_sessions()
|
||||
except Exception:
|
||||
logger.exception("idle eviction loop error")
|
||||
|
||||
async def _evict_idle_sessions(self) -> None:
|
||||
"""Close oldest idle sessions when over cap, or any past TTL."""
|
||||
now = asyncio.get_event_loop().time()
|
||||
# First: drop sessions past idle TTL (excluding just-created ones)
|
||||
to_remove: list[str] = []
|
||||
for sid, session in self._sessions.items():
|
||||
if session.profile_key and session.profile_key.startswith("auth-capture-"):
|
||||
continue # ephemeral sessions are handled separately
|
||||
last_active = self._last_event_at.get(sid, 0.0)
|
||||
if last_active > 0 and (now - last_active) > self.IDLE_TTL_SECONDS:
|
||||
to_remove.append(sid)
|
||||
for sid in to_remove:
|
||||
logger.info("evicting idle session %s (no activity for >%ds)", sid[:12], self.IDLE_TTL_SECONDS)
|
||||
await self.close(sid)
|
||||
|
||||
# Second: if still over cap, evict oldest by last_event_at
|
||||
persistent = [(sid, s) for sid, s in self._sessions.items()
|
||||
if not (s.profile_key or "").startswith("auth-capture-")]
|
||||
if len(persistent) > self.MAX_SESSIONS:
|
||||
persistent.sort(key=lambda x: self._last_event_at.get(x[0], 0.0))
|
||||
excess = len(persistent) - self.MAX_SESSIONS
|
||||
for sid, _ in persistent[:excess]:
|
||||
logger.info("evicting session %s (over cap of %d)", sid[:12], self.MAX_SESSIONS)
|
||||
await self.close(sid)
|
||||
|
||||
async def clear_profile(self, custom_page_id: int, url: str) -> None:
|
||||
"""Close session for the page if active, then delete profile directory.
|
||||
|
||||
Raises RuntimeError if the directory cannot be fully removed.
|
||||
"""
|
||||
import shutil
|
||||
# Close active session and use its profile_key (precise match)
|
||||
profile_key: Optional[str] = None
|
||||
try:
|
||||
session = self.find_by_page_id(custom_page_id)
|
||||
profile_key = session.profile_key
|
||||
await self.close(session.id)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Fallback: compute from URL (may be wrong if URL changed since session was created)
|
||||
if not profile_key:
|
||||
profile_key = self._profile_key(custom_page_id, url)
|
||||
|
||||
profile_dir = self._profile_dir(profile_key)
|
||||
if profile_dir.exists():
|
||||
shutil.rmtree(profile_dir) # no ignore_errors — let failure surface
|
||||
if profile_dir.exists():
|
||||
raise RuntimeError(
|
||||
f"Failed to fully remove browser profile directory: {profile_dir}"
|
||||
)
|
||||
logger.info("cleared browser profile for page %d: %s", custom_page_id, profile_dir)
|
||||
|
||||
def _profile_dir(self, profile_key: str) -> Path:
|
||||
root = Path(get_settings().browser_profiles_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -383,6 +555,13 @@ class BrowserSessionService:
|
||||
viewport={"width": width, "height": height},
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
# Grant clipboard access for the page origin
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||
except Exception:
|
||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
session = BrowserSession(
|
||||
id=session_id,
|
||||
@@ -394,6 +573,7 @@ class BrowserSessionService:
|
||||
captured_headers=[],
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
self._touch(session.id)
|
||||
# Start CDP network capture BEFORE the initial page load,
|
||||
# so we capture login redirects and auth headers from the start.
|
||||
await self._start_cdp_capture(session)
|
||||
|
||||
@@ -62,6 +62,18 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
snapshot = build_snapshot(
|
||||
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
|
||||
)
|
||||
# ── Balance fetch (inside with block, client still open) ──
|
||||
balance: Optional[float] = None
|
||||
if upstream.balance_endpoint and upstream.balance_response_path:
|
||||
try:
|
||||
raw_balance = client.get_balance(upstream.balance_endpoint, upstream.balance_response_path)
|
||||
if raw_balance is not None:
|
||||
divisor = upstream.balance_divisor or 1.0
|
||||
balance = raw_balance / divisor
|
||||
except Exception as exc:
|
||||
logger.warning("upstream %s balance fetch failed: %s", upstream.name, exc)
|
||||
upstream.balance = balance
|
||||
upstream.balance_updated_at = datetime.now(timezone.utc) if balance is not None else None
|
||||
except Exception as exc:
|
||||
# failure path
|
||||
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
||||
@@ -83,6 +95,7 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
return
|
||||
|
||||
# success path (client auto-closed by `with`)
|
||||
|
||||
prev_snapshot_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
|
||||
|
||||
@@ -34,10 +34,18 @@ def _clean_auth_header_value(value: Any, field_name: str) -> str:
|
||||
return ""
|
||||
if text.startswith("Bearer "):
|
||||
text = text[7:].strip()
|
||||
# Try to sanitize non-latin-1 characters instead of hard-failing
|
||||
try:
|
||||
text.encode("latin-1")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise UpstreamError(f"{field_name} contains non-HTTP-header characters; please re-extract and apply the full credential") from exc
|
||||
except UnicodeEncodeError:
|
||||
# Try stripping non-ASCII characters
|
||||
cleaned = text.encode("ascii", errors="ignore").decode("ascii").strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
raise UpstreamError(
|
||||
f"{field_name} 含有非 HTTP 标头字符(如中文或 emoji),"
|
||||
f"请重新登录后再试"
|
||||
) from None
|
||||
return text
|
||||
|
||||
|
||||
@@ -325,3 +333,30 @@ class UpstreamClient:
|
||||
|
||||
def get_group_rates(self, endpoint: str) -> Any:
|
||||
return self._request("GET", endpoint)
|
||||
|
||||
def get_balance(self, endpoint: str, response_path: str) -> Optional[float]:
|
||||
"""Call the balance endpoint and extract a numeric value using a dot-separated JSON path.
|
||||
|
||||
response_path 示例:
|
||||
"balance" → resp["balance"]
|
||||
"data.quota" → resp["data"]["quota"]
|
||||
"data.total_balance" → resp["data"]["total_balance"]
|
||||
"""
|
||||
if not endpoint or not response_path:
|
||||
return None
|
||||
resp = self._request("GET", endpoint)
|
||||
if not isinstance(resp, dict):
|
||||
return None
|
||||
parts = response_path.split(".")
|
||||
value: Any = resp
|
||||
for part in parts:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
else:
|
||||
return None
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -59,6 +59,11 @@ export interface UpstreamData {
|
||||
last_status: string
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
balance: number | null
|
||||
balance_updated_at: string | null
|
||||
balance_endpoint: string
|
||||
balance_response_path: string
|
||||
balance_divisor: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -74,6 +79,9 @@ export interface UpstreamForm {
|
||||
enabled: boolean
|
||||
check_interval_seconds: number
|
||||
timeout_seconds: number
|
||||
balance_endpoint: string
|
||||
balance_response_path: string
|
||||
balance_divisor: number
|
||||
}
|
||||
|
||||
export const upstreamsApi = {
|
||||
@@ -315,7 +323,10 @@ export const browserSessionsApi = {
|
||||
event: (id: string, data: BrowserEventPayload) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
||||
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
|
||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||
autofillLogin: (id: string) => api.post<{ success: boolean; message: string }>(`/api/browser-sessions/${id}/autofill-login`),
|
||||
clearProfile: (customPageId: number) => api.delete(`/api/browser-sessions/profiles/${customPageId}`),
|
||||
screenshotUrl: (id: string, token?: string) => {
|
||||
const params = new URLSearchParams({ t: String(Date.now()) })
|
||||
if (token) params.set('token', token)
|
||||
|
||||
@@ -13,7 +13,7 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/upstreams',
|
||||
redirect: '/websites',
|
||||
children: [
|
||||
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||
@@ -32,7 +32,7 @@ router.beforeEach((to, _from, next) => {
|
||||
if (to.meta.requiresAuth && !auth.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.token) {
|
||||
next('/upstreams')
|
||||
next('/websites')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
<el-icon><Key /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="canAutofillLogin" content="填入账号密码">
|
||||
<el-button size="small" text type="primary" :loading="autofilling" @click="triggerAutofillLogin">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="重建浏览器(保留登录态)">
|
||||
<el-button size="small" text @click="reconnectBrowser" :disabled="isStartingRemoteBrowser">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="清除登录态(删除 profile,需重新登录)">
|
||||
<el-button size="small" text type="danger" :loading="clearingProfile" @click="clearProfile">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="在新标签页打开">
|
||||
<el-button size="small" text @click="openExternal">
|
||||
<el-icon><TopRight /></el-icon>
|
||||
@@ -134,9 +149,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
|
||||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -172,7 +187,10 @@ const remoteScreenshotUrl = ref('')
|
||||
const isStartingRemoteBrowser = ref(false)
|
||||
const isReconnectingRemoteBrowser = ref(false)
|
||||
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
|
||||
let startRemoteBrowserPromise: Promise<void> | null = null
|
||||
const lastSyncedClipboard = ref('')
|
||||
const startRemoteBrowserPromises = new Map<number, Promise<void>>()
|
||||
let closingRemoteSessionPromise: Promise<void> | null = null
|
||||
let wsFirstFrameTimer: number | undefined
|
||||
let screenshotObjectUrl = ''
|
||||
let previousScreenshotObjectUrl = ''
|
||||
let pendingScreenshotBlob: Blob | null = null
|
||||
@@ -216,6 +234,13 @@ const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
||||
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
|
||||
const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
|
||||
const refreshingAuth = ref(false)
|
||||
const clearingProfile = ref(false)
|
||||
const autofilling = ref(false)
|
||||
const canAutofillLogin = computed(() =>
|
||||
isRemoteBrowser.value && remoteSession.value &&
|
||||
page.value?.login_autofill_enabled && page.value?.login_username &&
|
||||
page.value?.login_password_configured
|
||||
)
|
||||
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
|
||||
const embedded = computed(() => props.embedded)
|
||||
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
|
||||
@@ -236,10 +261,12 @@ async function loadPage(id: number) {
|
||||
clearRemoteError()
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
// Guard: page may have changed during async fetch
|
||||
if (effectivePageId.value !== id) return
|
||||
page.value = res.data.find(p => p.id === id) || null
|
||||
if (!page.value) {
|
||||
ElMessage.error('页面不存在')
|
||||
router.push('/custom-pages')
|
||||
if (effectivePageId.value === id) router.push('/custom-pages')
|
||||
return
|
||||
}
|
||||
if (isRemoteBrowser.value) {
|
||||
@@ -278,11 +305,16 @@ function reload() {
|
||||
}
|
||||
|
||||
async function startRemoteBrowser(options: { reconnect?: boolean } = {}) {
|
||||
if (startRemoteBrowserPromise) return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromise = doStartRemoteBrowser(options).finally(() => {
|
||||
startRemoteBrowserPromise = null
|
||||
const pid = effectivePageId.value
|
||||
const existing = startRemoteBrowserPromises.get(pid)
|
||||
if (existing) return existing
|
||||
const promise = doStartRemoteBrowser(options).finally(() => {
|
||||
if (startRemoteBrowserPromises.get(pid) === promise) {
|
||||
startRemoteBrowserPromises.delete(pid)
|
||||
}
|
||||
})
|
||||
return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromises.set(pid, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
@@ -296,14 +328,23 @@ async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
clearRemoteError()
|
||||
await nextTick()
|
||||
stopRemoteWs()
|
||||
await closeRemoteSession()
|
||||
// Wait for any in-flight close to finish (avoids profile contention)
|
||||
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
|
||||
// Capture the intended page id BEFORE the async request
|
||||
const requestedPageId = page.value.id
|
||||
try {
|
||||
const viewport = remoteViewport()
|
||||
const res = await browserSessionsApi.create({
|
||||
custom_page_id: page.value.id,
|
||||
custom_page_id: requestedPageId,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
})
|
||||
// Guard: page may have changed while we were creating.
|
||||
// Verify both the current route AND the backend-returned page match.
|
||||
if (effectivePageId.value !== requestedPageId || res.data.custom_page_id !== requestedPageId) {
|
||||
await browserSessionsApi.close(res.data.id).catch(() => {})
|
||||
return
|
||||
}
|
||||
remoteSession.value = res.data
|
||||
if (!options.reconnect) wsReconnectAttempts = 0
|
||||
if (props.active) {
|
||||
@@ -334,10 +375,21 @@ function connectRemoteWs() {
|
||||
|
||||
socket.onopen = () => {
|
||||
wsReconnectAttempts = 0
|
||||
// First-frame watchdog: if no screenshot arrives within 10s, show error
|
||||
wsFirstFrameTimer = window.setTimeout(() => {
|
||||
if (ws === socket) {
|
||||
handleRemoteSessionFailure(502, '远程浏览器无响应(首帧超时)')
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (evt.data instanceof Blob) {
|
||||
// Clear first-frame watchdog when first screenshot arrives
|
||||
if (wsFirstFrameTimer !== undefined) {
|
||||
window.clearTimeout(wsFirstFrameTimer)
|
||||
wsFirstFrameTimer = undefined
|
||||
}
|
||||
queueRemoteScreenshot(evt.data)
|
||||
return
|
||||
}
|
||||
@@ -381,6 +433,10 @@ function connectRemoteWs() {
|
||||
}
|
||||
|
||||
function stopRemoteWs() {
|
||||
if (wsFirstFrameTimer !== undefined) {
|
||||
window.clearTimeout(wsFirstFrameTimer)
|
||||
wsFirstFrameTimer = undefined
|
||||
}
|
||||
if (wsReconnectTimer !== undefined) {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = undefined
|
||||
@@ -454,6 +510,28 @@ async function refreshAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerAutofillLogin() {
|
||||
const sid = remoteSession.value?.id
|
||||
const cpid = page.value?.id
|
||||
if (!sid) return
|
||||
autofilling.value = true
|
||||
try {
|
||||
const res = await browserSessionsApi.autofillLogin(sid)
|
||||
// Guard: page or session may have changed during async request
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
if (res.data.success) {
|
||||
ElMessage.success(res.data.message)
|
||||
} else {
|
||||
ElMessage.warning(res.data.message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
ElMessage.error(e.response?.data?.detail || '填入失败')
|
||||
} finally {
|
||||
autofilling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function remoteViewport() {
|
||||
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
||||
return {
|
||||
@@ -555,6 +633,7 @@ function onRemotePointerUp(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemotePointerCancel(event: PointerEvent) {
|
||||
@@ -565,6 +644,7 @@ function onRemotePointerCancel(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemoteWheel(event: WheelEvent) {
|
||||
@@ -639,12 +719,109 @@ function focusRemoteFrame() {
|
||||
remoteFrameRef.value?.focus()
|
||||
}
|
||||
|
||||
const CLIPBOARD_SYNC_DELAY_MS = 800
|
||||
|
||||
async function syncClipboard(capturedSessionId?: string) {
|
||||
// Must be called with a captured sessionId; if none, capture now
|
||||
const sid = capturedSessionId || remoteSession.value?.id
|
||||
const cpid = page.value?.id
|
||||
if (!sid) return
|
||||
await new Promise((r) => setTimeout(r, CLIPBOARD_SYNC_DELAY_MS))
|
||||
// Guard: session or page may have changed during delay
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
try {
|
||||
const res = await browserSessionsApi.clipboard(sid)
|
||||
const data = res.data
|
||||
if (!data.text) {
|
||||
if (data.error && data.error !== '远程剪贴板为空') {
|
||||
console.debug('clipboard sync:', data.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.text === lastSyncedClipboard.value) return
|
||||
const text = data.text
|
||||
lastSyncedClipboard.value = text
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已同步到本机剪贴板')
|
||||
} catch {
|
||||
// Browser blocked clipboard write — try execCommand fallback
|
||||
let copied = false
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} catch {}
|
||||
document.body.removeChild(textarea)
|
||||
|
||||
if (copied) {
|
||||
ElMessage.success('已同步到本机剪贴板')
|
||||
} else {
|
||||
// Show dialog with selectable text as last resort
|
||||
ElMessageBox.alert(text, '远程剪贴板内容', {
|
||||
confirmButtonText: '已复制',
|
||||
type: 'info',
|
||||
dangerouslyUseHTMLString: false,
|
||||
message: `请手动复制下方内容:\n\n${text}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Clipboard read failed — silently ignore (likely empty or permission denied)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearProfile() {
|
||||
if (!page.value || !remoteSession.value) return
|
||||
try {
|
||||
clearingProfile.value = true
|
||||
await ElMessageBox.confirm('清除登录态后需要重新登录,确定继续?', '确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
clearingProfile.value = false
|
||||
return
|
||||
}
|
||||
stopRemoteWs()
|
||||
try {
|
||||
await browserSessionsApi.clearProfile(page.value.id)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '清除登录态失败')
|
||||
clearingProfile.value = false
|
||||
return
|
||||
}
|
||||
remoteSession.value = null
|
||||
setRemoteScreenshotUrl('')
|
||||
clearingProfile.value = false
|
||||
startRemoteBrowser()
|
||||
}
|
||||
|
||||
async function closeRemoteSession() {
|
||||
if (!remoteSession.value) return
|
||||
if (!remoteSession.value) {
|
||||
// If already null but a close is in flight, wait for it
|
||||
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
|
||||
return
|
||||
}
|
||||
const id = remoteSession.value.id
|
||||
remoteSession.value = null
|
||||
setRemoteScreenshotUrl('')
|
||||
await browserSessionsApi.close(id).catch(() => undefined)
|
||||
remoteErrorState.value = null
|
||||
iframeLoading.value = true
|
||||
const promise = browserSessionsApi.close(id).then<void | undefined>(() => undefined).catch(() => undefined)
|
||||
closingRemoteSessionPromise = promise
|
||||
try {
|
||||
await promise
|
||||
} finally {
|
||||
if (closingRemoteSessionPromise === promise) {
|
||||
closingRemoteSessionPromise = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueRemoteScreenshot(blob: Blob) {
|
||||
@@ -698,6 +875,13 @@ function retryRemoteBrowser() {
|
||||
startRemoteBrowser({ reconnect: true })
|
||||
}
|
||||
|
||||
/** Close current session and start fresh (login preserved via profile on disk). */
|
||||
async function reconnectBrowser() {
|
||||
stopRemoteWs()
|
||||
await closeRemoteSession()
|
||||
startRemoteBrowser()
|
||||
}
|
||||
|
||||
function clearRemoteError() {
|
||||
remoteErrorState.value = null
|
||||
}
|
||||
@@ -759,6 +943,16 @@ function mapRemoteBrowserError(status?: number, message?: string): RemoteBrowser
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerDetail.includes('首帧超时')) {
|
||||
return {
|
||||
title: '远程浏览器无响应',
|
||||
description: '浏览器已启动但长时间未返回画面,可能是页面卡死、弹窗遮挡或加载过慢。',
|
||||
hint: '点击按钮可重建浏览器并刷新页面连接。',
|
||||
actionLabel: '重新创建会话',
|
||||
technicalDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 502) {
|
||||
return {
|
||||
title: '远程浏览器连接异常',
|
||||
@@ -792,28 +986,33 @@ async function handleRemoteSessionFailure(status: number | undefined, message: s
|
||||
setRemoteError(status, message || '远程浏览器会话已失效')
|
||||
}
|
||||
|
||||
watch(() => route.params.id, (id) => {
|
||||
watch(() => route.params.id, async (id) => {
|
||||
if (!props.pageId && id) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(Number(id))
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
watch(() => props.pageId, (id, oldId) => {
|
||||
watch(() => props.pageId, async (id, oldId) => {
|
||||
if (id && id !== oldId) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(id)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
watch(() => props.active, async (active) => {
|
||||
if (!isRemoteBrowser.value) return
|
||||
if (active) {
|
||||
if (remoteSession.value && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
if (remoteSession.value) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
connectRemoteWs()
|
||||
}
|
||||
} else {
|
||||
// Session was closed while inactive — restart from profile
|
||||
await startRemoteBrowser()
|
||||
}
|
||||
nextTick(() => remoteFrameRef.value?.focus())
|
||||
} else {
|
||||
stopRemoteWs()
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<div class="metric-value">{{ metrics.unhealthy }}</div>
|
||||
<p class="metric-note">需要处理错误或网络异常的节点</p>
|
||||
</article>
|
||||
<article class="surface-card metric-card">
|
||||
<div class="metric-label">Balance</div>
|
||||
<div class="metric-value">{{ metrics.balanceCount }}</div>
|
||||
<p class="metric-note">已配置余额接口的上游节点数</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="upstreams-content">
|
||||
@@ -71,6 +76,15 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="余额" width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.balance !== null && row.balance !== undefined" class="balance-value mono">
|
||||
{{ formatBalance(row.balance) }}
|
||||
</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
@@ -111,9 +125,9 @@
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text @click="openDetail(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
@@ -198,7 +212,7 @@
|
||||
<div class="timeline-name">{{ row.name }}</div>
|
||||
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button size="small" text @click="openDetail(row)">查看</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">还没有检测记录</div>
|
||||
@@ -286,6 +300,17 @@
|
||||
<el-form-item label="倍率接口">
|
||||
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
|
||||
</el-form-item>
|
||||
<el-form-item label="余额接口">
|
||||
<el-input v-model="form.balance_endpoint" placeholder="留空则不获取余额,如 /auth/me" />
|
||||
</el-form-item>
|
||||
<el-form-item label="余额字段路径">
|
||||
<el-input v-model="form.balance_response_path" placeholder="如 balance、data.quota" />
|
||||
<div class="form-hint">JSON 点分路径,例如 <code>balance</code> 或 <code>data.quota</code></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="余额除数">
|
||||
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
||||
<div class="form-hint">原始值除以该数得到实际余额。New-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="检测间隔(秒)">
|
||||
@@ -322,6 +347,14 @@
|
||||
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="surface-card info-card" v-if="detailUpstream.balance !== null">
|
||||
<div class="info-label">余额</div>
|
||||
<div class="info-value mono">{{ formatBalance(detailUpstream.balance) }}</div>
|
||||
</div>
|
||||
<div class="surface-card info-card" v-if="detailUpstream.balance_updated_at">
|
||||
<div class="info-label">余额更新于</div>
|
||||
<div class="info-value mono">{{ fmtTimeFull(detailUpstream.balance_updated_at) }}</div>
|
||||
</div>
|
||||
<div class="surface-card info-card">
|
||||
<div class="info-label">最近检测</div>
|
||||
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
|
||||
@@ -443,6 +476,9 @@ const defaultForm = () => ({
|
||||
enabled: true,
|
||||
check_interval_seconds: 600,
|
||||
timeout_seconds: 30,
|
||||
balance_endpoint: '',
|
||||
balance_response_path: '',
|
||||
balance_divisor: 1.0,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
@@ -513,11 +549,16 @@ function handlePlatformChange(val: string) {
|
||||
form.value.auth_type = 'login_password'
|
||||
form.value.auth_config.login_path = '/auth/login'
|
||||
form.value.auth_config.username_field = 'email'
|
||||
form.value.balance_endpoint = '/auth/me'
|
||||
form.value.balance_response_path = 'data.balance'
|
||||
} 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'
|
||||
@@ -525,6 +566,12 @@ function handlePlatformChange(val: string) {
|
||||
form.value.auth_type = 'login_password'
|
||||
form.value.auth_config.login_path = '/api/user/login'
|
||||
form.value.auth_config.username_field = 'username'
|
||||
form.value.balance_endpoint = '/api/user/self'
|
||||
form.value.balance_response_path = 'data.quota'
|
||||
form.value.balance_divisor = 500000
|
||||
} else {
|
||||
form.value.balance_endpoint = ''
|
||||
form.value.balance_response_path = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +588,7 @@ const metrics = computed(() => ({
|
||||
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
|
||||
enabled: list.value.filter((item) => item.enabled).length,
|
||||
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
|
||||
balanceCount: list.value.filter((item) => item.balance_endpoint).length,
|
||||
}))
|
||||
|
||||
const healthyRate = computed(() => {
|
||||
@@ -573,6 +621,11 @@ const recentChecks = computed(() =>
|
||||
|
||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||
|
||||
function formatBalance(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
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 fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
||||
@@ -616,6 +669,9 @@ function openEdit(row: UpstreamData) {
|
||||
enabled: row.enabled,
|
||||
check_interval_seconds: row.check_interval_seconds,
|
||||
timeout_seconds: row.timeout_seconds,
|
||||
balance_endpoint: row.balance_endpoint || '',
|
||||
balance_response_path: row.balance_response_path || '',
|
||||
balance_divisor: row.balance_divisor ?? 1.0,
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
@@ -1076,6 +1132,25 @@ onMounted(loadList)
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-soft);
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-hint code {
|
||||
background: var(--bg-soft);
|
||||
padding: 0 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.snap-pagination {
|
||||
justify-content: center;
|
||||
margin-top: 0.9rem;
|
||||
@@ -1130,4 +1205,13 @@ onMounted(loadList)
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row .el-button--danger {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
}
|
||||
.action-row .el-button--danger:hover {
|
||||
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
|
||||
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,12 +58,12 @@
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="连接测试" placement="top" :show-after="300">
|
||||
<el-button size="small" circle text type="success" :loading="row._testing" @click="testWebsite(row)">
|
||||
<el-button size="small" circle text :loading="row._testing" @click="testWebsite(row)">
|
||||
<el-icon v-if="!row._testing"><Connection /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="新增绑定" placement="top" :show-after="300">
|
||||
<el-button size="small" circle text type="primary" @click="openBindingCreate(row)">
|
||||
<el-button size="small" circle text @click="openBindingCreate(row)">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
@@ -95,7 +95,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
||||
<el-button size="small" text :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">分组绑定</div>
|
||||
<el-button size="small" type="primary" :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||
</div>
|
||||
<div class="binding-list" v-loading="bindingLoading">
|
||||
<div v-for="binding in bindings" :key="binding.id" class="binding-item">
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
<div class="binding-actions">
|
||||
<el-switch v-model="binding.enabled" @change="toggleBinding(binding)" />
|
||||
<el-button size="small" text type="primary" :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
|
||||
<el-button size="small" text :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
|
||||
<el-button size="small" text @click="openBindingEdit(binding)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button size="small" text type="danger" @click="deleteBinding(binding)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
@@ -647,12 +647,23 @@ onMounted(loadAll)
|
||||
height: 26px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.binding-actions .el-button--danger,
|
||||
.action-row .el-button--danger {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
}
|
||||
.binding-actions .el-button--danger:hover,
|
||||
.action-row .el-button--danger:hover {
|
||||
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
|
||||
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
|
||||
}
|
||||
.binding-list { min-height: 120px; }
|
||||
.binding-item {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user