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