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
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.git-real/
|
.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
|
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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
async with existing.lock:
|
# Health check: verify session can actually serve content
|
||||||
await existing.page.set_viewport_size({"width": width, "height": height})
|
healthy = True
|
||||||
if existing.page.url == "about:blank":
|
try:
|
||||||
await existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
async with existing.lock:
|
||||||
await self._autofill_login(existing.page, login_config)
|
url_before = existing.page.url
|
||||||
await self._reset_page_zoom(existing)
|
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 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
|
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)
|
||||||
submit_selector = str(config.get("submit_selector") or "").strip()
|
if not skip_submit:
|
||||||
if submit_selector:
|
submit_selector = str(config.get("submit_selector") or "").strip()
|
||||||
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
|
if submit_selector:
|
||||||
if submit:
|
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
|
||||||
await submit.click(timeout=3000)
|
if submit:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 +986,32 @@ 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) {
|
||||||
connectRemoteWs()
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
connectRemoteWs()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Session was closed while inactive — restart from profile
|
||||||
|
await startRemoteBrowser()
|
||||||
}
|
}
|
||||||
nextTick(() => remoteFrameRef.value?.focus())
|
nextTick(() => remoteFrameRef.value?.focus())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user