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:
liumangmang
2026-05-20 09:44:20 +08:00
parent 4c71148ff9
commit 6cc797f915
16 changed files with 773 additions and 52 deletions
+4
View File
@@ -21,3 +21,7 @@ backend/data/
*.log *.log
.DS_Store .DS_Store
.git-real/ .git-real/
# 外部项目(不提交)
/sub2api/
/new-api/
+20
View File
@@ -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 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) Base.metadata.create_all(bind=engine)
_migrate_custom_pages() _migrate_custom_pages()
_migrate_upstreams()
def _migrate_custom_pages(): def _migrate_custom_pages():
@@ -67,3 +68,22 @@ def _migrate_custom_pages():
if "linked_upstream_id" not in columns: if "linked_upstream_id" not in columns:
conn.execute(text("ALTER TABLE custom_pages ADD COLUMN linked_upstream_id INTEGER")) 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"))
+7 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional 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 sqlalchemy.orm import mapped_column, Mapped
from app.database import Base from app.database import Base
@@ -26,6 +26,12 @@ class Upstream(Base):
last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) 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)) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
+102
View File
@@ -131,11 +131,113 @@ async def get_selection(session_id: str, _=Depends(get_current_user)):
raise _error_from_browser(exc) 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) @router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)): async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id) 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 ——— # ——— WebSocket stream ———
# Frame interval & diff detection # Frame interval & diff detection
_WS_MIN_INTERVAL = 0.10 _WS_MIN_INTERVAL = 0.10
+14 -1
View File
@@ -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"] existing_config["new_api_user"] = candidate["new_api_user"]
elif ctype == "bearer_token": elif ctype == "bearer_token":
upstream.auth_type = "bearer" 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": elif ctype == "api_key":
upstream.auth_type = "api_key" upstream.auth_type = "api_key"
existing_config["key"] = candidate.get("value", "") existing_config["key"] = candidate.get("value", "")
+31
View File
@@ -2,9 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -55,6 +58,11 @@ def _to_response(u: Upstream) -> UpstreamResponse:
last_status=u.last_status, last_status=u.last_status,
last_checked_at=u.last_checked_at, last_checked_at=u.last_checked_at,
last_error=u.last_error, 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, created_at=u.created_at,
updated_at=u.updated_at, updated_at=u.updated_at,
) )
@@ -82,6 +90,9 @@ def create_upstream(
enabled=body.enabled, enabled=body.enabled,
check_interval_seconds=body.check_interval_seconds, check_interval_seconds=body.check_interval_seconds,
timeout_seconds=body.timeout_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.add(u)
db.commit() db.commit()
@@ -156,6 +167,16 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
try: try:
client.login() client.login()
groups = client.get_available_groups(u.groups_endpoint) 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_status = "healthy"
u.last_error = None u.last_error = None
u.last_checked_at = datetime.now(timezone.utc) 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) groups = client.get_available_groups(u.groups_endpoint)
raw_rates = client.get_group_rates(u.rate_endpoint) raw_rates = client.get_group_rates(u.rate_endpoint)
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates) 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: except Exception as exc:
u.consecutive_failures = (u.consecutive_failures or 0) + 1 u.consecutive_failures = (u.consecutive_failures or 0) + 1
u.last_error = str(exc) u.last_error = str(exc)
+11
View File
@@ -29,6 +29,9 @@ class UpstreamCreate(BaseModel):
enabled: bool = True enabled: bool = True
check_interval_seconds: int = 600 check_interval_seconds: int = 600
timeout_seconds: int = 30 timeout_seconds: int = 30
balance_endpoint: str = ""
balance_response_path: str = ""
balance_divisor: float = 1.0
class UpstreamUpdate(BaseModel): class UpstreamUpdate(BaseModel):
@@ -42,6 +45,9 @@ class UpstreamUpdate(BaseModel):
enabled: Optional[bool] = None enabled: Optional[bool] = None
check_interval_seconds: Optional[int] = None check_interval_seconds: Optional[int] = None
timeout_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): class UpstreamResponse(BaseModel):
@@ -59,6 +65,11 @@ class UpstreamResponse(BaseModel):
last_status: str last_status: str
last_checked_at: Optional[datetime] last_checked_at: Optional[datetime]
last_error: Optional[str] 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 created_at: datetime
updated_at: datetime updated_at: datetime
+188 -8
View File
@@ -37,11 +37,18 @@ class BrowserSession:
class BrowserSessionService: 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: def __init__(self) -> None:
self._playwright: Optional[Any] = None self._playwright: Optional[Any] = None
self._sessions: dict[str, BrowserSession] = {} self._sessions: dict[str, BrowserSession] = {}
self._profiles: dict[str, str] = {} self._profiles: dict[str, str] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._last_event_at: dict[str, float] = {}
self._evict_task: Optional[asyncio.Task[None]] = None
async def create( async def create(
self, self,
@@ -61,21 +68,43 @@ class BrowserSessionService:
existing_id = self._profiles.get(profile_key) existing_id = self._profiles.get(profile_key)
existing = self._sessions.get(existing_id or "") existing = self._sessions.get(existing_id or "")
if existing and not existing.page.is_closed(): if existing and not existing.page.is_closed():
# Health check: verify session can actually serve content
healthy = True
try:
async with existing.lock: 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}) 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 existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
await self._autofill_login(existing.page, login_config) await self._autofill_login(existing.page, login_config)
await self._reset_page_zoom(existing) 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 return existing
# Close unhealthy session (profile stays on disk)
await self.close(existing.id)
if existing_id: if existing_id:
self._profiles.pop(profile_key, None) 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( context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)), str(self._profile_dir(profile_key)),
headless=get_settings().browser_headless, headless=get_settings().browser_headless,
viewport={"width": width, "height": height}, viewport={"width": width, "height": height},
args=["--no-sandbox", "--disable-dev-shm-usage"], 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() page = context.pages[0] if context.pages else await context.new_page()
session = BrowserSession( session = BrowserSession(
id=uuid4().hex, id=uuid4().hex,
@@ -87,6 +116,9 @@ class BrowserSessionService:
) )
self._sessions[session.id] = session self._sessions[session.id] = session
self._profiles[profile_key] = session.id 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: try:
await page.goto(url, wait_until="domcontentloaded", timeout=45000) await page.goto(url, wait_until="domcontentloaded", timeout=45000)
await self._autofill_login(page, login_config) await self._autofill_login(page, login_config)
@@ -96,8 +128,13 @@ class BrowserSessionService:
raise raise
return session 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: async def screenshot(self, session_id: str) -> bytes:
session = self._get(session_id) session = self._get(session_id)
self._touch(session_id)
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
return await session.page.screenshot(type="jpeg", quality=65, full_page=False) return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
@@ -111,6 +148,7 @@ class BrowserSessionService:
include_state: bool = True, include_state: bool = True,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
session = self._get(session_id) session = self._get(session_id)
self._last_event_at[session_id] = asyncio.get_event_loop().time()
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
page = session.page page = session.page
@@ -156,12 +194,51 @@ class BrowserSessionService:
async def selected_text(self, session_id: str) -> str: async def selected_text(self, session_id: str) -> str:
session = self._get(session_id) session = self._get(session_id)
self._touch(session_id)
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''") value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
return str(value or "") 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: async def close(self, session_id: str) -> None:
self._last_event_at.pop(session_id, None)
session = self._discard_session(session_id) session = self._discard_session(session_id)
if not session: if not session:
return return
@@ -185,6 +262,14 @@ class BrowserSessionService:
pass pass
async def shutdown(self) -> None: 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) sessions = list(self._sessions)
for session_id in sessions: for session_id in sessions:
await self.close(session_id) await self.close(session_id)
@@ -194,6 +279,7 @@ class BrowserSessionService:
async def state(self, session_id: str) -> dict[str, Any]: async def state(self, session_id: str) -> dict[str, Any]:
session = self._get(session_id) session = self._get(session_id)
self._touch(session_id)
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
return await self._session_state(session) return await self._session_state(session)
@@ -217,6 +303,9 @@ class BrowserSessionService:
self._playwright = await async_playwright().start() self._playwright = await async_playwright().start()
except Exception as exc: except Exception as exc:
raise BrowserDependencyError(f"Unable to start Playwright: {exc}") from 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: async def _reset_page_zoom(self, session: BrowserSession) -> None:
try: try:
@@ -228,20 +317,38 @@ class BrowserSessionService:
except Exception: except Exception:
pass 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( async def _autofill_login(
self, self,
page: Any, page: Any,
config: Optional[dict[str, Any]], config: Optional[dict[str, Any]],
*, *,
max_wait_seconds: float = 8.0, max_wait_seconds: float = 2.0,
poll_interval_seconds: float = 0.25, poll_interval_seconds: float = 0.25,
) -> None: skip_submit: bool = False,
) -> bool:
if not config or not config.get("enabled"): if not config or not config.get("enabled"):
return return False
username = str(config.get("username") or "") username = str(config.get("username") or "")
password = str(config.get("password") or "") password = str(config.get("password") or "")
if not username or not password: if not username or not password:
return return False
try: try:
username_selectors = [ username_selectors = [
config.get("username_selector"), config.get("username_selector"),
@@ -268,17 +375,20 @@ class BrowserSessionService:
poll_interval_seconds=poll_interval_seconds, poll_interval_seconds=poll_interval_seconds,
) )
if not username_locator or not password_locator: if not username_locator or not password_locator:
logger.info("Login autofill skipped for %s: login fields not found", page.url) logger.info("Login autofill skipped: login fields not found")
return return False
await username_locator.fill(username, timeout=3000) await username_locator.fill(username, timeout=3000)
await password_locator.fill(password, timeout=3000) await password_locator.fill(password, timeout=3000)
if not skip_submit:
submit_selector = str(config.get("submit_selector") or "").strip() submit_selector = str(config.get("submit_selector") or "").strip()
if submit_selector: if submit_selector:
submit = await self._first_visible_locator(page, [submit_selector], timeout=500) submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
if submit: if submit:
await submit.click(timeout=3000) await submit.click(timeout=3000)
return True
except Exception as exc: 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( async def _wait_for_login_locators(
self, self,
@@ -345,6 +455,68 @@ class BrowserSessionService:
self._profiles.pop(session.profile_key, None) self._profiles.pop(session.profile_key, None)
return session 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: def _profile_dir(self, profile_key: str) -> Path:
root = Path(get_settings().browser_profiles_dir) root = Path(get_settings().browser_profiles_dir)
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
@@ -383,6 +555,13 @@ class BrowserSessionService:
viewport={"width": width, "height": height}, viewport={"width": width, "height": height},
args=["--no-sandbox", "--disable-dev-shm-usage"], 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() page = context.pages[0] if context.pages else await context.new_page()
session = BrowserSession( session = BrowserSession(
id=session_id, id=session_id,
@@ -394,6 +573,7 @@ class BrowserSessionService:
captured_headers=[], captured_headers=[],
) )
self._sessions[session.id] = session self._sessions[session.id] = session
self._touch(session.id)
# Start CDP network capture BEFORE the initial page load, # Start CDP network capture BEFORE the initial page load,
# so we capture login redirects and auth headers from the start. # so we capture login redirects and auth headers from the start.
await self._start_cdp_capture(session) await self._start_cdp_capture(session)
+13
View File
@@ -62,6 +62,18 @@ def _check_upstream(upstream_id: int) -> None:
snapshot = build_snapshot( snapshot = build_snapshot(
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates 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: except Exception as exc:
# failure path # failure path
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1 upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
@@ -83,6 +95,7 @@ def _check_upstream(upstream_id: int) -> None:
return return
# success path (client auto-closed by `with`) # success path (client auto-closed by `with`)
prev_snapshot_row = ( prev_snapshot_row = (
db.query(UpstreamRateSnapshot) db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == upstream_id) .filter(UpstreamRateSnapshot.upstream_id == upstream_id)
+37 -2
View File
@@ -34,10 +34,18 @@ def _clean_auth_header_value(value: Any, field_name: str) -> str:
return "" return ""
if text.startswith("Bearer "): if text.startswith("Bearer "):
text = text[7:].strip() text = text[7:].strip()
# Try to sanitize non-latin-1 characters instead of hard-failing
try: try:
text.encode("latin-1") text.encode("latin-1")
except UnicodeEncodeError as exc: except UnicodeEncodeError:
raise UpstreamError(f"{field_name} contains non-HTTP-header characters; please re-extract and apply the full credential") from exc # 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 return text
@@ -325,3 +333,30 @@ class UpstreamClient:
def get_group_rates(self, endpoint: str) -> Any: def get_group_rates(self, endpoint: str) -> Any:
return self._request("GET", endpoint) 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
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+11
View File
@@ -59,6 +59,11 @@ export interface UpstreamData {
last_status: string last_status: string
last_checked_at: string | null last_checked_at: string | null
last_error: 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 created_at: string
updated_at: string updated_at: string
} }
@@ -74,6 +79,9 @@ export interface UpstreamForm {
enabled: boolean enabled: boolean
check_interval_seconds: number check_interval_seconds: number
timeout_seconds: number timeout_seconds: number
balance_endpoint: string
balance_response_path: string
balance_divisor: number
} }
export const upstreamsApi = { export const upstreamsApi = {
@@ -315,7 +323,10 @@ export const browserSessionsApi = {
event: (id: string, data: BrowserEventPayload) => event: (id: string, data: BrowserEventPayload) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data), api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`), 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}`), 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) => { screenshotUrl: (id: string, token?: string) => {
const params = new URLSearchParams({ t: String(Date.now()) }) const params = new URLSearchParams({ t: String(Date.now()) })
if (token) params.set('token', token) if (token) params.set('token', token)
+2 -2
View File
@@ -13,7 +13,7 @@ const router = createRouter({
path: '/', path: '/',
component: () => import('@/components/AppLayout.vue'), component: () => import('@/components/AppLayout.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
redirect: '/upstreams', redirect: '/websites',
children: [ children: [
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') }, { path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
{ path: 'websites', component: () => import('@/views/Websites.vue') }, { path: 'websites', component: () => import('@/views/Websites.vue') },
@@ -32,7 +32,7 @@ router.beforeEach((to, _from, next) => {
if (to.meta.requiresAuth && !auth.token) { if (to.meta.requiresAuth && !auth.token) {
next('/login') next('/login')
} else if (to.path === '/login' && auth.token) { } else if (to.path === '/login' && auth.token) {
next('/upstreams') next('/websites')
} else { } else {
next() next()
} }
+217 -18
View File
@@ -33,6 +33,21 @@
<el-icon><Key /></el-icon> <el-icon><Key /></el-icon>
</el-button> </el-button>
</el-tooltip> </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-tooltip content="在新标签页打开">
<el-button size="small" text @click="openExternal"> <el-button size="small" text @click="openExternal">
<el-icon><TopRight /></el-icon> <el-icon><TopRight /></el-icon>
@@ -134,9 +149,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { 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, Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
@@ -172,7 +187,10 @@ const remoteScreenshotUrl = ref('')
const isStartingRemoteBrowser = ref(false) const isStartingRemoteBrowser = ref(false)
const isReconnectingRemoteBrowser = ref(false) const isReconnectingRemoteBrowser = ref(false)
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null) 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 screenshotObjectUrl = ''
let previousScreenshotObjectUrl = '' let previousScreenshotObjectUrl = ''
let pendingScreenshotBlob: Blob | null = null 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 isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value) const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
const refreshingAuth = ref(false) 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 effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
const embedded = computed(() => props.embedded) const embedded = computed(() => props.embedded)
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value) const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
@@ -236,10 +261,12 @@ async function loadPage(id: number) {
clearRemoteError() clearRemoteError()
try { try {
const res = await customPagesApi.list() 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 page.value = res.data.find(p => p.id === id) || null
if (!page.value) { if (!page.value) {
ElMessage.error('页面不存在') ElMessage.error('页面不存在')
router.push('/custom-pages') if (effectivePageId.value === id) router.push('/custom-pages')
return return
} }
if (isRemoteBrowser.value) { if (isRemoteBrowser.value) {
@@ -278,11 +305,16 @@ function reload() {
} }
async function startRemoteBrowser(options: { reconnect?: boolean } = {}) { async function startRemoteBrowser(options: { reconnect?: boolean } = {}) {
if (startRemoteBrowserPromise) return startRemoteBrowserPromise const pid = effectivePageId.value
startRemoteBrowserPromise = doStartRemoteBrowser(options).finally(() => { const existing = startRemoteBrowserPromises.get(pid)
startRemoteBrowserPromise = null 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 }) { async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
@@ -296,14 +328,23 @@ async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
clearRemoteError() clearRemoteError()
await nextTick() await nextTick()
stopRemoteWs() 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 { try {
const viewport = remoteViewport() const viewport = remoteViewport()
const res = await browserSessionsApi.create({ const res = await browserSessionsApi.create({
custom_page_id: page.value.id, custom_page_id: requestedPageId,
width: viewport.width, width: viewport.width,
height: viewport.height, 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 remoteSession.value = res.data
if (!options.reconnect) wsReconnectAttempts = 0 if (!options.reconnect) wsReconnectAttempts = 0
if (props.active) { if (props.active) {
@@ -334,10 +375,21 @@ function connectRemoteWs() {
socket.onopen = () => { socket.onopen = () => {
wsReconnectAttempts = 0 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) => { socket.onmessage = (evt) => {
if (evt.data instanceof Blob) { if (evt.data instanceof Blob) {
// Clear first-frame watchdog when first screenshot arrives
if (wsFirstFrameTimer !== undefined) {
window.clearTimeout(wsFirstFrameTimer)
wsFirstFrameTimer = undefined
}
queueRemoteScreenshot(evt.data) queueRemoteScreenshot(evt.data)
return return
} }
@@ -381,6 +433,10 @@ function connectRemoteWs() {
} }
function stopRemoteWs() { function stopRemoteWs() {
if (wsFirstFrameTimer !== undefined) {
window.clearTimeout(wsFirstFrameTimer)
wsFirstFrameTimer = undefined
}
if (wsReconnectTimer !== undefined) { if (wsReconnectTimer !== undefined) {
window.clearTimeout(wsReconnectTimer) window.clearTimeout(wsReconnectTimer)
wsReconnectTimer = undefined 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() { function remoteViewport() {
const rect = remoteFrameRef.value?.getBoundingClientRect() const rect = remoteFrameRef.value?.getBoundingClientRect()
return { return {
@@ -555,6 +633,7 @@ function onRemotePointerUp(event: PointerEvent) {
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId) remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
if (!point) return if (!point) return
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button }) sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
syncClipboard(remoteSession.value?.id)
} }
function onRemotePointerCancel(event: PointerEvent) { function onRemotePointerCancel(event: PointerEvent) {
@@ -565,6 +644,7 @@ function onRemotePointerCancel(event: PointerEvent) {
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId) remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
if (!point) return if (!point) return
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button }) sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
syncClipboard(remoteSession.value?.id)
} }
function onRemoteWheel(event: WheelEvent) { function onRemoteWheel(event: WheelEvent) {
@@ -639,12 +719,109 @@ function focusRemoteFrame() {
remoteFrameRef.value?.focus() 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() { 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 const id = remoteSession.value.id
remoteSession.value = null remoteSession.value = null
setRemoteScreenshotUrl('') 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) { function queueRemoteScreenshot(blob: Blob) {
@@ -698,6 +875,13 @@ function retryRemoteBrowser() {
startRemoteBrowser({ reconnect: true }) startRemoteBrowser({ reconnect: true })
} }
/** Close current session and start fresh (login preserved via profile on disk). */
async function reconnectBrowser() {
stopRemoteWs()
await closeRemoteSession()
startRemoteBrowser()
}
function clearRemoteError() { function clearRemoteError() {
remoteErrorState.value = null 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) { if (status === 502) {
return { return {
title: '远程浏览器连接异常', title: '远程浏览器连接异常',
@@ -792,28 +986,33 @@ async function handleRemoteSessionFailure(status: number | undefined, message: s
setRemoteError(status, message || '远程浏览器会话已失效') setRemoteError(status, message || '远程浏览器会话已失效')
} }
watch(() => route.params.id, (id) => { watch(() => route.params.id, async (id) => {
if (!props.pageId && id) { if (!props.pageId && id) {
stopRemoteWs() stopRemoteWs()
closeRemoteSession() await closeRemoteSession()
loadPage(Number(id)) loadPage(Number(id))
} }
}, { immediate: false }) }, { immediate: false })
watch(() => props.pageId, (id, oldId) => { watch(() => props.pageId, async (id, oldId) => {
if (id && id !== oldId) { if (id && id !== oldId) {
stopRemoteWs() stopRemoteWs()
closeRemoteSession() await closeRemoteSession()
loadPage(id) loadPage(id)
} }
}) })
watch(() => props.active, (active) => { watch(() => props.active, async (active) => {
if (!isRemoteBrowser.value) return if (!isRemoteBrowser.value) return
if (active) { if (active) {
if (remoteSession.value && (!ws || ws.readyState !== WebSocket.OPEN)) { if (remoteSession.value) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectRemoteWs() connectRemoteWs()
} }
} else {
// Session was closed while inactive — restart from profile
await startRemoteBrowser()
}
nextTick(() => remoteFrameRef.value?.focus()) nextTick(() => remoteFrameRef.value?.focus())
} else { } else {
stopRemoteWs() stopRemoteWs()
+88 -4
View File
@@ -42,6 +42,11 @@
<div class="metric-value">{{ metrics.unhealthy }}</div> <div class="metric-value">{{ metrics.unhealthy }}</div>
<p class="metric-note">需要处理错误或网络异常的节点</p> <p class="metric-note">需要处理错误或网络异常的节点</p>
</article> </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>
<section class="upstreams-content"> <section class="upstreams-content">
@@ -71,6 +76,15 @@
</template> </template>
</el-table-column> </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"> <el-table-column label="启用" width="88" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" /> <el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
@@ -111,9 +125,9 @@
<el-button size="small" text @click="openEdit(row)" title="编辑"> <el-button size="small" text @click="openEdit(row)" title="编辑">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
</el-button> </el-button>
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button> <el-button size="small" text @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 @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text type="info" @click="openDetail(row)"> <el-button size="small" text @click="openDetail(row)">
<el-icon><List /></el-icon> <el-icon><List /></el-icon>
详情 详情
</el-button> </el-button>
@@ -198,7 +212,7 @@
<div class="timeline-name">{{ row.name }}</div> <div class="timeline-name">{{ row.name }}</div>
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div> <div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
</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> </div>
<div v-else class="empty-hint side-empty">还没有检测记录</div> <div v-else class="empty-hint side-empty">还没有检测记录</div>
@@ -286,6 +300,17 @@
<el-form-item label="倍率接口"> <el-form-item label="倍率接口">
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" /> <el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
</el-form-item> </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-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="检测间隔(秒)"> <el-form-item label="检测间隔(秒)">
@@ -322,6 +347,14 @@
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }} <span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
</span> </span>
</div> </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="surface-card info-card">
<div class="info-label">最近检测</div> <div class="info-label">最近检测</div>
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div> <div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
@@ -443,6 +476,9 @@ const defaultForm = () => ({
enabled: true, enabled: true,
check_interval_seconds: 600, check_interval_seconds: 600,
timeout_seconds: 30, timeout_seconds: 30,
balance_endpoint: '',
balance_response_path: '',
balance_divisor: 1.0,
}) })
const form = ref(defaultForm()) const form = ref(defaultForm())
const rules = { const rules = {
@@ -513,11 +549,16 @@ function handlePlatformChange(val: string) {
form.value.auth_type = 'login_password' form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/auth/login' form.value.auth_config.login_path = '/auth/login'
form.value.auth_config.username_field = 'email' 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') { } else if (val === 'new-api') {
form.value.api_prefix = '' form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/' form.value.groups_endpoint = '/api/group/'
form.value.rate_endpoint = '/api/option/?key=GroupRatio' form.value.rate_endpoint = '/api/option/?key=GroupRatio'
form.value.auth_type = 'bearer' 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') { } else if (val === 'new-api-user') {
form.value.api_prefix = '' form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups' 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_type = 'login_password'
form.value.auth_config.login_path = '/api/user/login' form.value.auth_config.login_path = '/api/user/login'
form.value.auth_config.username_field = 'username' 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, healthy: list.value.filter((item) => item.last_status === 'healthy').length,
enabled: list.value.filter((item) => item.enabled).length, enabled: list.value.filter((item) => item.enabled).length,
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length, unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
balanceCount: list.value.filter((item) => item.balance_endpoint).length,
})) }))
const healthyRate = computed(() => { const healthyRate = computed(() => {
@@ -573,6 +621,11 @@ 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', cookie: 'Cookie', 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)
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 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')
@@ -616,6 +669,9 @@ function openEdit(row: UpstreamData) {
enabled: row.enabled, enabled: row.enabled,
check_interval_seconds: row.check_interval_seconds, check_interval_seconds: row.check_interval_seconds,
timeout_seconds: row.timeout_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 drawerVisible.value = true
} }
@@ -1076,6 +1132,25 @@ onMounted(loadList)
color: var(--color-warning); 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 { .snap-pagination {
justify-content: center; justify-content: center;
margin-top: 0.9rem; margin-top: 0.9rem;
@@ -1130,4 +1205,13 @@ onMounted(loadList)
align-items: flex-start; 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> </style>
+16 -5
View File
@@ -58,12 +58,12 @@
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip content="连接测试" placement="top" :show-after="300"> <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-icon v-if="!row._testing"><Connection /></el-icon>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip content="新增绑定" placement="top" :show-after="300"> <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-icon><Link /></el-icon>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
@@ -95,7 +95,7 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -104,7 +104,7 @@
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title">分组绑定</div> <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>
<div class="binding-list" v-loading="bindingLoading"> <div class="binding-list" v-loading="bindingLoading">
<div v-for="binding in bindings" :key="binding.id" class="binding-item"> <div v-for="binding in bindings" :key="binding.id" class="binding-item">
@@ -117,7 +117,7 @@
</div> </div>
<div class="binding-actions"> <div class="binding-actions">
<el-switch v-model="binding.enabled" @change="toggleBinding(binding)" /> <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 @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> <el-button size="small" text type="danger" @click="deleteBinding(binding)"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -647,12 +647,23 @@ onMounted(loadAll)
height: 26px; height: 26px;
margin-left: 0; margin-left: 0;
} }
.binding-actions { .binding-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; 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-list { min-height: 120px; }
.binding-item { .binding-item {
display: flex; display: flex;