diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py index 8df79609..35e12f7b 100644 --- a/src/core/upload/sub2api_upload.py +++ b/src/core/upload/sub2api_upload.py @@ -3,25 +3,165 @@ 将账号以 sub2api-data 格式批量导入到 Sub2API 平台 """ -import json import logging from datetime import datetime, timezone -from typing import List, Tuple, Optional +from typing import Any, List, Optional, Tuple from curl_cffi import requests as cffi_requests -from ...database.session import get_db from ...database.models import Account +from ...database.session import get_db logger = logging.getLogger(__name__) +def _extract_sub2api_data(response) -> Any: + try: + payload = response.json() + except Exception: + payload = None + + if response.status_code >= 400: + if isinstance(payload, dict) and payload.get("message"): + raise ValueError(str(payload["message"])) + response_text = (response.text or "").strip() + raise ValueError( + f"HTTP {response.status_code}" + (f" - {response_text[:200]}" if response_text else "") + ) + + if isinstance(payload, dict): + if payload.get("code") not in (None, 0): + raise ValueError(str(payload.get("message") or "Sub2API 返回错误")) + return payload.get("data", payload) + + if payload is None: + raise ValueError("Sub2API 返回了无法解析的响应") + + return payload + + +def _normalize_remote_sub2api_proxy(proxy: dict) -> dict: + protocol = str(proxy.get("protocol") or "").strip() + host = str(proxy.get("host") or "").strip() + try: + port = int(proxy.get("port") or 0) + except (TypeError, ValueError): + port = 0 + + normalized = { + "id": proxy.get("id"), + "name": str(proxy.get("name") or "").strip(), + "protocol": protocol, + "host": host, + "port": port, + "username": str(proxy.get("username") or "").strip(), + "password": str(proxy.get("password") or "").strip(), + "status": str(proxy.get("status") or "inactive").strip() or "inactive", + } + if not normalized["name"]: + if normalized["id"] is not None: + normalized["name"] = f"Proxy {normalized['id']}" + else: + normalized["name"] = f"{protocol}://{host}:{port}" + return normalized + + +def _build_sub2api_proxy_key(proxy: dict) -> str: + normalized = _normalize_remote_sub2api_proxy(proxy) + return ( + f"{normalized['protocol']}|{normalized['host']}|{normalized['port']}|" + f"{normalized['username']}|{normalized['password']}" + ) + + +def _build_sub2api_proxy_payload(proxy: dict) -> dict: + normalized = _normalize_remote_sub2api_proxy(proxy) + if not normalized["protocol"] or not normalized["host"] or normalized["port"] <= 0: + raise ValueError(f"远端 Sub2API 代理 {normalized['id']} 配置不完整,无法生成上传数据") + + return { + "proxy_key": _build_sub2api_proxy_key(normalized), + "name": normalized["name"], + "protocol": normalized["protocol"], + "host": normalized["host"], + "port": normalized["port"], + "username": normalized["username"], + "password": normalized["password"], + "status": normalized["status"], + } + + +def fetch_remote_sub2api_proxies(api_url: str, api_key: str) -> List[dict]: + if not api_url: + raise ValueError("Sub2API URL 未配置") + if not api_key: + raise ValueError("Sub2API API Key 未配置") + + url = api_url.rstrip("/") + "/api/v1/admin/proxies/all" + + try: + response = cffi_requests.get( + url, + headers={"x-api-key": api_key}, + proxies=None, + timeout=15, + impersonate="chrome110", + ) + data = _extract_sub2api_data(response) + if not isinstance(data, list): + raise ValueError("远端 Sub2API 代理列表格式异常") + return [_normalize_remote_sub2api_proxy(item) for item in data if isinstance(item, dict)] + except ValueError: + raise + except cffi_requests.exceptions.ConnectionError as e: + raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e + except cffi_requests.exceptions.Timeout as e: + raise ValueError("拉取远端 Sub2API 代理列表超时") from e + except Exception as e: + logger.error(f"拉取远端 Sub2API 代理列表异常: {e}") + raise ValueError(f"拉取远端 Sub2API 代理列表失败: {e}") from e + + +def fetch_remote_sub2api_proxy(api_url: str, api_key: str, proxy_id: int) -> dict: + if proxy_id is None: + raise ValueError("远端 Sub2API 代理 ID 不能为空") + if not api_url: + raise ValueError("Sub2API URL 未配置") + if not api_key: + raise ValueError("Sub2API API Key 未配置") + + url = api_url.rstrip("/") + f"/api/v1/admin/proxies/{proxy_id}" + + try: + response = cffi_requests.get( + url, + headers={"x-api-key": api_key}, + proxies=None, + timeout=15, + impersonate="chrome110", + ) + data = _extract_sub2api_data(response) + if not isinstance(data, dict): + raise ValueError("远端 Sub2API 代理详情格式异常") + return _normalize_remote_sub2api_proxy(data) + except ValueError: + raise + except cffi_requests.exceptions.ConnectionError as e: + raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e + except cffi_requests.exceptions.Timeout as e: + raise ValueError("拉取远端 Sub2API 代理详情超时") from e + except Exception as e: + logger.error(f"拉取远端 Sub2API 代理详情异常: {e}") + raise ValueError(f"拉取远端 Sub2API 代理详情失败: {e}") from e + + def upload_to_sub2api( accounts: List[Account], api_url: str, api_key: str, concurrency: int = 3, priority: int = 50, + proxy_id: Optional[int] = None, ) -> Tuple[bool, str]: """ 上传账号列表到 Sub2API 平台(不走代理) @@ -32,6 +172,7 @@ def upload_to_sub2api( api_key: Admin API Key(x-api-key header) concurrency: 账号并发数,默认 3 priority: 账号优先级,默认 50 + proxy_id: 远端 Sub2API 代理 ID,用于生成 Sub2API 识别的 proxy_key(可选) Returns: (成功标志, 消息) @@ -47,12 +188,20 @@ def upload_to_sub2api( exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + proxy_payload = None + if proxy_id is not None: + try: + remote_proxy = fetch_remote_sub2api_proxy(api_url, api_key, proxy_id) + proxy_payload = _build_sub2api_proxy_payload(remote_proxy) + except ValueError as e: + return False, str(e) + account_items = [] for acc in accounts: if not acc.access_token: continue expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0 - account_items.append({ + account_item = { "name": acc.email, "platform": "openai", "type": "oauth", @@ -82,7 +231,10 @@ def upload_to_sub2api( "priority": priority, "rate_multiplier": 1, "auto_pause_on_expired": True, - }) + } + if proxy_payload is not None: + account_item["proxy_key"] = proxy_payload["proxy_key"] + account_items.append(account_item) if not account_items: return False, "所有账号均缺少 access_token,无法上传" @@ -92,7 +244,7 @@ def upload_to_sub2api( "type": "sub2api-data", "version": 1, "exported_at": exported_at, - "proxies": [], + "proxies": [proxy_payload] if proxy_payload is not None else [], "accounts": account_items, }, "skip_default_group_bind": True, @@ -138,6 +290,7 @@ def batch_upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, + proxy_id: Optional[int] = None, ) -> dict: """ 批量上传指定 ID 的账号到 Sub2API 平台 @@ -169,7 +322,14 @@ def batch_upload_to_sub2api( if not accounts: return results - success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) + success, message = upload_to_sub2api( + accounts, + api_url, + api_key, + concurrency, + priority, + proxy_id=proxy_id, + ) if success: for acc in accounts: diff --git a/src/database/crud.py b/src/database/crud.py index 4750969c..e14dd3c5 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -596,13 +596,15 @@ def create_sub2api_service( api_url: str, api_key: str, enabled: bool = True, - priority: int = 0 + priority: int = 0, + default_remote_proxy_id: Optional[int] = None, ) -> Sub2ApiService: """创建 Sub2API 服务配置""" svc = Sub2ApiService( name=name, api_url=api_url, api_key=api_key, + default_remote_proxy_id=default_remote_proxy_id, enabled=enabled, priority=priority, ) diff --git a/src/database/models.py b/src/database/models.py index f662917c..17880e04 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -152,6 +152,7 @@ class Sub2ApiService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL (host) api_key = Column(Text, nullable=False) # x-api-key + default_remote_proxy_id = Column(Integer, nullable=True) # 默认远端代理 ID enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) diff --git a/src/database/session.py b/src/database/session.py index c16541be..4c1dd244 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -111,6 +111,7 @@ def migrate_tables(self): ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), + ("sub2api_services", "default_remote_proxy_id", "INTEGER"), ] # 确保新表存在(create_tables 已处理,此处兜底) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a6a597fc..5f6c86f2 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -796,6 +796,7 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID class BatchSub2ApiUploadRequest(BaseModel): @@ -808,6 +809,7 @@ class BatchSub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的 concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID @router.post("/batch-upload-sub2api") @@ -817,6 +819,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): # 解析指定的 Sub2API 服务 api_url = None api_key = None + svc = None if request.service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, request.service_id) @@ -828,10 +831,11 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - api_url = svcs[0].api_url - api_key = svcs[0].api_key + svc = svcs[0] + api_url = svc.api_url + api_key = svc.api_key - if not api_url or not api_key: + if not api_url or not api_key or not svc: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -840,10 +844,13 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): request.status_filter, request.email_service_filter, request.search_filter ) + selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id + results = batch_upload_to_sub2api( ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, + proxy_id=selected_proxy_id, ) return results @@ -855,9 +862,11 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp service_id = request.service_id if request else None concurrency = request.concurrency if request else 3 priority = request.priority if request else 50 + proxy_id = request.proxy_id if request and "proxy_id" in request.model_fields_set else None api_url = None api_key = None + svc = None if service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, service_id) @@ -869,10 +878,11 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - api_url = svcs[0].api_url - api_key = svcs[0].api_key + svc = svcs[0] + api_url = svc.api_url + api_key = svc.api_key - if not api_url or not api_key: + if not api_url or not api_key or not svc: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -884,7 +894,9 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, - concurrency=concurrency, priority=priority + concurrency=concurrency, + priority=priority, + proxy_id=proxy_id if request and "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id, ) if success: return {"success": True, "message": message} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index daa92e5e..da386fe4 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -462,7 +462,12 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if not _svc: continue log_callback(f"[Sub2API] 正在把账号发往服务站: {_svc.name}") - _ok, _msg = upload_to_sub2api([saved_account], _svc.api_url, _svc.api_key) + _ok, _msg = upload_to_sub2api( + [saved_account], + _svc.api_url, + _svc.api_key, + proxy_id=_svc.default_remote_proxy_id, + ) log_callback(f"[Sub2API] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") except Exception as _e: log_callback(f"[Sub2API] 异常({_sid}): {_e}") diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index ddd77592..7843ea2b 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -8,7 +8,11 @@ from ....database import crud from ....database.session import get_db -from ....core.upload.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api +from ....core.upload.sub2api_upload import ( + batch_upload_to_sub2api, + fetch_remote_sub2api_proxies, + test_sub2api_connection, +) router = APIRouter() @@ -19,6 +23,7 @@ class Sub2ApiServiceCreate(BaseModel): name: str api_url: str api_key: str + default_remote_proxy_id: Optional[int] = None enabled: bool = True priority: int = 0 @@ -27,6 +32,7 @@ class Sub2ApiServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None + default_remote_proxy_id: Optional[int] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -36,6 +42,7 @@ class Sub2ApiServiceResponse(BaseModel): name: str api_url: str has_key: bool + default_remote_proxy_id: Optional[int] = None enabled: bool priority: int created_at: Optional[str] = None @@ -46,8 +53,10 @@ class Config: class Sub2ApiTestRequest(BaseModel): + service_id: Optional[int] = None api_url: Optional[str] = None api_key: Optional[str] = None + default_remote_proxy_id: Optional[int] = None class Sub2ApiUploadRequest(BaseModel): @@ -55,6 +64,22 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None + + +class Sub2ApiRemoteProxyResponse(BaseModel): + id: int + name: str + protocol: str + host: str + port: int + username: str = "" + status: str + + +class Sub2ApiRemoteProxyListResponse(BaseModel): + service: Sub2ApiServiceResponse + proxies: List[Sub2ApiRemoteProxyResponse] def _to_response(svc) -> Sub2ApiServiceResponse: @@ -63,6 +88,7 @@ def _to_response(svc) -> Sub2ApiServiceResponse: name=svc.name, api_url=svc.api_url, has_key=bool(svc.api_key), + default_remote_proxy_id=svc.default_remote_proxy_id, enabled=svc.enabled, priority=svc.priority, created_at=svc.created_at.isoformat() if svc.created_at else None, @@ -70,6 +96,52 @@ def _to_response(svc) -> Sub2ApiServiceResponse: ) +def _resolve_sub2api_service(service_id: Optional[int] = None): + with get_db() as db: + if service_id: + svc = crud.get_sub2api_service_by_id(db, service_id) + else: + services = crud.get_sub2api_services(db, enabled=True) + svc = services[0] if services else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务") + return svc + + +def _to_remote_proxy_response(proxy: dict) -> Sub2ApiRemoteProxyResponse: + proxy_id = proxy.get("id") + if proxy_id is None: + raise ValueError("远端 Sub2API 代理缺少 id") + + return Sub2ApiRemoteProxyResponse( + id=int(proxy_id), + name=str(proxy.get("name") or "").strip() or f"Proxy {proxy_id}", + protocol=str(proxy.get("protocol") or "").strip(), + host=str(proxy.get("host") or "").strip(), + port=int(proxy.get("port") or 0), + username=str(proxy.get("username") or "").strip(), + status=str(proxy.get("status") or "inactive").strip() or "inactive", + ) + + +def _resolve_temp_sub2api_request(request: Sub2ApiTestRequest): + svc = None + if request.service_id is not None: + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, request.service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + + api_url = (request.api_url or (svc.api_url if svc else "")).strip() + api_key = (request.api_key or (svc.api_key if svc else "")).strip() + + if not api_url or not api_key: + raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") + + return svc, api_url, api_key + + # ============== API Endpoints ============== @router.get("", response_model=List[Sub2ApiServiceResponse]) @@ -91,10 +163,55 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): api_key=request.api_key, enabled=request.enabled, priority=request.priority, + default_remote_proxy_id=request.default_remote_proxy_id, ) return _to_response(svc) +@router.get("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) +async def list_remote_sub2api_proxies(service_id: Optional[int] = None): + """拉取目标 Sub2API 服务中的远端代理列表""" + svc = _resolve_sub2api_service(service_id) + + try: + proxies = fetch_remote_sub2api_proxies(svc.api_url, svc.api_key) + proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + return Sub2ApiRemoteProxyListResponse( + service=_to_response(svc), + proxies=proxy_items, + ) + + +@router.post("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) +async def list_remote_sub2api_proxies_direct(request: Sub2ApiTestRequest): + """按临时 Sub2API 配置拉取远端代理列表(用于新增前选择默认代理)""" + svc, api_url, api_key = _resolve_temp_sub2api_request(request) + + try: + proxies = fetch_remote_sub2api_proxies(api_url, api_key) + proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + return Sub2ApiRemoteProxyListResponse( + service=_to_response(svc) if svc else Sub2ApiServiceResponse( + id=0, + name="未保存服务", + api_url=api_url, + has_key=bool(api_key), + default_remote_proxy_id=request.default_remote_proxy_id, + enabled=True, + priority=0, + created_at=None, + updated_at=None, + ), + proxies=proxy_items, + ) + + @router.get("/{service_id}", response_model=Sub2ApiServiceResponse) async def get_sub2api_service(service_id: int): """获取单个 Sub2API 服务详情""" @@ -117,6 +234,7 @@ async def get_sub2api_service_full(service_id: int): "name": svc.name, "api_url": svc.api_url, "api_key": svc.api_key, + "default_remote_proxy_id": svc.default_remote_proxy_id, "enabled": svc.enabled, "priority": svc.priority, } @@ -138,6 +256,8 @@ async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate) # api_key 留空则保持原值 if request.api_key: update_data["api_key"] = request.api_key + if "default_remote_proxy_id" in request.model_fields_set: + update_data["default_remote_proxy_id"] = request.default_remote_proxy_id if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: @@ -172,9 +292,8 @@ async def test_sub2api_service(service_id: int): @router.post("/test-connection") async def test_sub2api_connection_direct(request: Sub2ApiTestRequest): """直接测试 Sub2API 连接(用于添加前验证)""" - if not request.api_url or not request.api_key: - raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") - success, message = test_sub2api_connection(request.api_url, request.api_key) + _svc, api_url, api_key = _resolve_temp_sub2api_request(request) + success, message = test_sub2api_connection(api_url, api_key) return {"success": success, "message": message} @@ -197,11 +316,14 @@ async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest): api_url = svc.api_url api_key = svc.api_key + selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id + results = batch_upload_to_sub2api( request.account_ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, + proxy_id=selected_proxy_id, ) return results diff --git a/static/css/style.css b/static/css/style.css index 4c40acc9..76305a2b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -357,6 +357,171 @@ body { border-top: 1px solid var(--border-light); } +.sub2api-remote-proxy-row { + display: flex; + align-items: stretch; + gap: var(--spacing-sm); +} + +.sub2api-remote-proxy-picker { + position: relative; + flex: 1; +} + +.sub2api-remote-proxy-trigger { + width: 100%; + min-height: 44px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + cursor: pointer; + transition: all var(--transition); + text-align: left; +} + +.sub2api-remote-proxy-trigger:hover, +.sub2api-remote-proxy-trigger[aria-expanded="true"] { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.sub2api-remote-proxy-trigger-content { + flex: 1; + min-width: 0; +} + +.sub2api-remote-proxy-trigger-arrow { + color: var(--text-muted); + font-size: 0.875rem; + flex-shrink: 0; +} + +.sub2api-remote-proxy-placeholder { + color: var(--text-muted); + font-size: 0.875rem; +} + +.sub2api-remote-proxy-summary-title-row, +.sub2api-remote-proxy-option-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.sub2api-remote-proxy-summary-name, +.sub2api-remote-proxy-option-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +.sub2api-remote-proxy-summary-meta, +.sub2api-remote-proxy-option-meta { + margin-top: 4px; + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.sub2api-remote-proxy-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-full); + background: var(--info-light); + color: var(--info-color); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.sub2api-remote-proxy-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 20; + display: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.sub2api-remote-proxy-dropdown.active { + display: block; +} + +.sub2api-remote-proxy-options { + max-height: 320px; + overflow-y: auto; + padding: var(--spacing-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.sub2api-remote-proxy-option { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: inherit; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; +} + +.sub2api-remote-proxy-option:hover { + background: var(--surface-hover); + border-color: var(--text-muted); +} + +.sub2api-remote-proxy-option.selected { + border-color: var(--primary-color); + background: var(--primary-light); +} + +.sub2api-remote-proxy-option-main { + min-width: 0; + flex: 1; +} + +.sub2api-remote-proxy-option-check { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); +} + +.sub2api-remote-proxy-option.selected .sub2api-remote-proxy-option-check { + color: var(--primary-color); +} + +.sub2api-remote-proxy-btn { + flex-shrink: 0; +} + +.sub2api-remote-proxy-hint { + margin-top: var(--spacing-xs); + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.5; +} + /* Checkbox 样式优化 */ .form-group input[type="checkbox"] { width: auto; diff --git a/static/js/accounts.js b/static/js/accounts.js index fe9848c3..60c86cc3 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -1022,6 +1022,83 @@ function selectSub2ApiService() { }); } +function selectSub2ApiProxy(serviceChoice) { + return new Promise(async (resolve) => { + const modal = document.getElementById('sub2api-proxy-modal'); + const listEl = document.getElementById('sub2api-proxy-list'); + const closeBtn = document.getElementById('close-sub2api-proxy-modal'); + const cancelBtn = document.getElementById('cancel-sub2api-proxy-modal-btn'); + const noProxyBtn = document.getElementById('sub2api-no-proxy-btn'); + + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let proxies = []; + try { + const query = serviceChoice && serviceChoice.service_id != null + ? `?service_id=${encodeURIComponent(serviceChoice.service_id)}` + : ''; + const result = await api.get(`/sub2api-services/remote-proxies${query}`); + proxies = result.proxies || []; + } catch (e) { + cleanup(); + toast.error('加载远端 Sub2API 代理失败: ' + e.message); + resolve(null); + return; + } + + if (proxies.length === 0) { + listEl.innerHTML = '
该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传
'; + } else { + listEl.innerHTML = proxies.map(proxy => ` +
+
+
+ #${proxy.id} ${escapeHtml(proxy.name)} + ${escapeHtml(String(proxy.protocol || '').toUpperCase())} + ${escapeHtml(proxy.status || 'inactive')} +
+
${escapeHtml(proxy.host)}:${proxy.port}
+
+ 选择 +
+ `).join(''); + + listEl.querySelectorAll('.sub2api-service-item').forEach(item => { + item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); + item.addEventListener('mouseleave', () => item.style.background = ''); + item.addEventListener('click', () => { + cleanup(); + resolve({ proxy_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + noProxyBtn.removeEventListener('click', onNoProxy); + } + function onCancel() { cleanup(); resolve(null); } + function onNoProxy() { cleanup(); resolve({ proxy_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + noProxyBtn.addEventListener('click', onNoProxy); + }); +} + // 批量上传到 Sub2API async function handleBatchUploadSub2Api() { const count = getEffectiveCount(); @@ -1030,6 +1107,9 @@ async function handleBatchUploadSub2Api() { const choice = await selectSub2ApiService(); if (choice === null) return; // 用户取消 + const proxyInput = await selectSub2ApiProxy(choice); + if (proxyInput === null) return; + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`); if (!confirmed) return; @@ -1039,6 +1119,7 @@ async function handleBatchUploadSub2Api() { try { const payload = buildBatchPayload(); if (choice.service_id != null) payload.service_id = choice.service_id; + payload.proxy_id = proxyInput.proxy_id; const result = await api.post('/accounts/batch-upload-sub2api', payload); let message = `成功: ${result.success_count}`; @@ -1060,10 +1141,15 @@ async function handleBatchUploadSub2Api() { async function uploadToSub2Api(id) { const choice = await selectSub2ApiService(); if (choice === null) return; + + const proxyInput = await selectSub2ApiProxy(choice); + if (proxyInput === null) return; + try { toast.info('正在上传到 Sub2API...'); const payload = {}; if (choice.service_id != null) payload.service_id = choice.service_id; + payload.proxy_id = proxyInput.proxy_id; const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); if (result.success) { toast.success('上传成功'); diff --git a/static/js/settings.js b/static/js/settings.js index 595f0381..a036b6b9 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -57,6 +57,10 @@ const elements = { sub2ApiServiceForm: document.getElementById('sub2api-service-form'), sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'), testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'), + loadSub2ApiRemoteProxiesBtn: document.getElementById('load-sub2api-remote-proxies-btn'), + sub2ApiRemoteProxyTrigger: document.getElementById('sub2api-remote-proxy-trigger'), + sub2ApiRemoteProxyDropdown: document.getElementById('sub2api-remote-proxy-dropdown'), + sub2ApiRemoteProxyOptions: document.getElementById('sub2api-remote-proxy-options'), // Team Manager 服务管理 addTmServiceBtn: document.getElementById('add-tm-service-btn'), tmServicesTable: document.getElementById('tm-services-table'), @@ -90,8 +94,11 @@ document.addEventListener('DOMContentLoaded', () => { initEventListeners(); }); -document.addEventListener('click', () => { +document.addEventListener('click', (e) => { document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); + if (!e.target.closest('.sub2api-remote-proxy-picker')) { + closeSub2ApiRemoteProxyDropdown(); + } }); // 初始化标签页 @@ -312,6 +319,26 @@ function initEventListeners() { if (elements.testSub2ApiServiceBtn) { elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService); } + if (elements.loadSub2ApiRemoteProxiesBtn) { + elements.loadSub2ApiRemoteProxiesBtn.addEventListener('click', () => loadSub2ApiRemoteProxiesForForm()); + } + if (elements.sub2ApiRemoteProxyTrigger) { + elements.sub2ApiRemoteProxyTrigger.addEventListener('click', (e) => { + e.stopPropagation(); + const opened = toggleSub2ApiRemoteProxyDropdown(); + if (opened) { + loadSub2ApiRemoteProxiesForForm({ silent: true }); + } + }); + } + if (elements.sub2ApiRemoteProxyOptions) { + elements.sub2ApiRemoteProxyOptions.addEventListener('click', (e) => { + const optionBtn = e.target.closest('.sub2api-remote-proxy-option'); + if (!optionBtn) return; + setSub2ApiDefaultRemoteProxyValue(optionBtn.dataset.proxyValue || ''); + closeSub2ApiRemoteProxyDropdown(); + }); + } } // 加载设置 @@ -1378,6 +1405,243 @@ async function handleTestCpaService() { // ============================================================================ let _sub2apiEditingId = null; +let _sub2apiRemoteProxyOptions = []; + +function normalizeSub2ApiRemoteProxyValue(value) { + return value == null || value === '' ? '' : String(value); +} + +function getSub2ApiDefaultRemoteProxyInput() { + return document.getElementById('sub2api-default-remote-proxy-id'); +} + +function getSub2ApiRemoteProxyTriggerContent() { + return document.getElementById('sub2api-remote-proxy-trigger-content'); +} + +function getSub2ApiRemoteProxyOptionByValue(value) { + const normalizedValue = normalizeSub2ApiRemoteProxyValue(value); + return _sub2apiRemoteProxyOptions.find(option => option.value === normalizedValue) || null; +} + +function getSub2ApiRemoteProxyStatusClass(status) { + const normalizedStatus = String(status || '').trim().toLowerCase(); + if (normalizedStatus === 'active') return 'active'; + if ([ 'inactive', 'disabled', 'offline' ].includes(normalizedStatus)) return 'disabled'; + if ([ 'error', 'failed' ].includes(normalizedStatus)) return 'error'; + if ([ 'warning', 'missing' ].includes(normalizedStatus)) return 'warning'; + return 'pending'; +} + +function buildSub2ApiRemoteProxySummary(option) { + if (!option || option.value === '') { + return '不设置默认代理'; + } + + const protocol = option.protocol + ? `${escapeHtml(option.protocol)}` + : ''; + const status = option.status + ? `${escapeHtml(option.status)}` + : ''; + const meta = option.meta + ? `
${escapeHtml(option.meta)}
` + : ''; + + return ` +
+ ${escapeHtml(option.title)} + ${protocol} + ${status} +
+ ${meta} + `; +} + +function buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue) { + const isSelected = option.value === selectedValue; + const protocol = option.protocol + ? `${escapeHtml(option.protocol)}` + : ''; + const status = option.status + ? `${escapeHtml(option.status)}` + : ''; + const meta = option.meta + ? `
${escapeHtml(option.meta)}
` + : ''; + + return ` + + `; +} + +function renderSub2ApiRemoteProxyTriggerDisplay() { + const triggerContent = getSub2ApiRemoteProxyTriggerContent(); + const input = getSub2ApiDefaultRemoteProxyInput(); + if (!triggerContent || !input) return; + + const option = getSub2ApiRemoteProxyOptionByValue(input.value); + triggerContent.innerHTML = buildSub2ApiRemoteProxySummary(option); +} + +function renderSub2ApiRemoteProxyOptionList() { + if (!elements.sub2ApiRemoteProxyOptions) return; + const selectedValue = normalizeSub2ApiRemoteProxyValue(getSub2ApiDefaultRemoteProxyInput()?.value); + elements.sub2ApiRemoteProxyOptions.innerHTML = _sub2apiRemoteProxyOptions + .map(option => buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue)) + .join(''); +} + +function setSub2ApiDefaultRemoteProxyValue(value) { + const input = getSub2ApiDefaultRemoteProxyInput(); + if (!input) return; + input.value = normalizeSub2ApiRemoteProxyValue(value); + renderSub2ApiRemoteProxyTriggerDisplay(); + renderSub2ApiRemoteProxyOptionList(); +} + +function closeSub2ApiRemoteProxyDropdown() { + if (elements.sub2ApiRemoteProxyDropdown) { + elements.sub2ApiRemoteProxyDropdown.classList.remove('active'); + } + if (elements.sub2ApiRemoteProxyTrigger) { + elements.sub2ApiRemoteProxyTrigger.setAttribute('aria-expanded', 'false'); + } +} + +function toggleSub2ApiRemoteProxyDropdown() { + if (!elements.sub2ApiRemoteProxyDropdown || !elements.sub2ApiRemoteProxyTrigger) return false; + const willOpen = !elements.sub2ApiRemoteProxyDropdown.classList.contains('active'); + elements.sub2ApiRemoteProxyDropdown.classList.toggle('active', willOpen); + elements.sub2ApiRemoteProxyTrigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); + return willOpen; +} + +function resetSub2ApiRemoteProxyOptions(selectedValue = '') { + const normalizedValue = normalizeSub2ApiRemoteProxyValue(selectedValue); + _sub2apiRemoteProxyOptions = [ + { + value: '', + title: '不设置默认代理', + meta: '上传时不默认绑定远端代理', + protocol: '', + status: '', + }, + ]; + + if (normalizedValue) { + _sub2apiRemoteProxyOptions.push({ + value: normalizedValue, + title: `当前默认代理 #${normalizedValue}`, + meta: '点击“加载代理”获取远端详情', + protocol: '', + status: 'warning', + }); + } + + setSub2ApiDefaultRemoteProxyValue(normalizedValue); + closeSub2ApiRemoteProxyDropdown(); +} + +function renderSub2ApiRemoteProxyOptions(proxies, selectedValue = '') { + const normalizedValue = normalizeSub2ApiRemoteProxyValue(selectedValue); + const remoteProxyOptions = (proxies || []).map(proxy => { + const protocol = String(proxy.protocol || '').trim().toUpperCase(); + const host = String(proxy.host || '').trim(); + const port = proxy.port != null ? `:${proxy.port}` : ''; + const meta = host ? `${host}${port}` : '未提供地址'; + const status = String(proxy.status || '').trim() || 'inactive'; + return { + value: String(proxy.id), + title: `#${proxy.id} ${proxy.name || `Proxy ${proxy.id}`}`, + meta, + protocol, + status, + }; + }); + + _sub2apiRemoteProxyOptions = [ + { + value: '', + title: '不设置默认代理', + meta: remoteProxyOptions.length === 0 ? '当前服务下暂无可选远端代理' : '上传时不默认绑定远端代理', + protocol: '', + status: '', + }, + ]; + + if (normalizedValue && !remoteProxyOptions.some(option => option.value === normalizedValue)) { + _sub2apiRemoteProxyOptions.push({ + value: normalizedValue, + title: `当前默认代理 #${normalizedValue}`, + meta: '远端已不存在,请重新选择或清空默认代理', + protocol: '', + status: 'warning', + }); + } + + _sub2apiRemoteProxyOptions.push(...remoteProxyOptions); + setSub2ApiDefaultRemoteProxyValue(normalizedValue); +} + +function setSub2ApiRemoteProxyLoading(loading) { + if (!elements.loadSub2ApiRemoteProxiesBtn) return; + elements.loadSub2ApiRemoteProxiesBtn.disabled = loading; + elements.loadSub2ApiRemoteProxiesBtn.textContent = loading ? '加载中...' : '加载代理'; +} + +async function loadSub2ApiRemoteProxiesForForm({ silent = false } = {}) { + const id = document.getElementById('sub2api-service-id').value.trim(); + const apiUrl = document.getElementById('sub2api-service-url').value.trim(); + const apiKey = document.getElementById('sub2api-service-key').value.trim(); + const input = getSub2ApiDefaultRemoteProxyInput(); + const selectedValue = input ? input.value : ''; + + if (!apiUrl) { + if (!silent) toast.error('请先填写 API URL'); + return; + } + if (!id && !apiKey) { + if (!silent) toast.error('请先填写 API Key'); + return; + } + + const payload = { api_url: apiUrl }; + if (id) payload.service_id = parseInt(id, 10); + if (apiKey) payload.api_key = apiKey; + + setSub2ApiRemoteProxyLoading(true); + try { + const result = await api.post('/sub2api-services/remote-proxies', payload); + const proxies = result.proxies || []; + renderSub2ApiRemoteProxyOptions(proxies, selectedValue); + if (!silent) { + if (proxies.length > 0) { + toast.success(`已加载 ${proxies.length} 个远端代理`); + } else { + toast.info('当前服务下暂无可选远端代理'); + } + } + } catch (e) { + if (!silent) { + toast.error('加载远端代理失败: ' + e.message); + } else { + console.error('加载远端代理失败:', e); + } + } finally { + setSub2ApiRemoteProxyLoading(false); + } +} async function loadSub2ApiServices() { try { @@ -1416,19 +1680,31 @@ function openSub2ApiServiceModal(svc = null) { elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务'; elements.sub2ApiServiceForm.reset(); document.getElementById('sub2api-service-id').value = svc ? svc.id : ''; + + resetSub2ApiRemoteProxyOptions(svc ? svc.default_remote_proxy_id : ''); + if (svc) { document.getElementById('sub2api-service-name').value = svc.name || ''; document.getElementById('sub2api-service-url').value = svc.api_url || ''; document.getElementById('sub2api-service-priority').value = svc.priority ?? 0; document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false; document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key'; + } else { + document.getElementById('sub2api-service-key').placeholder = '请输入 API Key'; } + elements.sub2ApiServiceEditModal.classList.add('active'); + + if (svc) { + loadSub2ApiRemoteProxiesForForm({ silent: true }); + } } function closeSub2ApiServiceModal() { elements.sub2ApiServiceEditModal.classList.remove('active'); elements.sub2ApiServiceForm.reset(); + resetSub2ApiRemoteProxyOptions(); + setSub2ApiRemoteProxyLoading(false); _sub2apiEditingId = null; } @@ -1455,10 +1731,13 @@ async function deleteSub2ApiService(id, name) { async function handleSaveSub2ApiService(e) { e.preventDefault(); const id = document.getElementById('sub2api-service-id').value; + const defaultRemoteProxyValue = getSub2ApiDefaultRemoteProxyInput()?.value || ''; + const parsedDefaultRemoteProxyId = defaultRemoteProxyValue === '' ? null : parseInt(defaultRemoteProxyValue, 10); const data = { name: document.getElementById('sub2api-service-name').value, api_url: document.getElementById('sub2api-service-url').value, api_key: document.getElementById('sub2api-service-key').value || undefined, + default_remote_proxy_id: Number.isNaN(parsedDefaultRemoteProxyId) ? null : parsedDefaultRemoteProxyId, priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0, enabled: document.getElementById('sub2api-service-enabled').checked, }; @@ -1499,7 +1778,7 @@ async function testSub2ApiServiceById(id) { async function handleTestSub2ApiService() { const apiUrl = document.getElementById('sub2api-service-url').value.trim(); const apiKey = document.getElementById('sub2api-service-key').value.trim(); - const id = document.getElementById('sub2api-service-id').value; + const id = document.getElementById('sub2api-service-id').value.trim(); if (!apiUrl) { toast.error('请先填写 API URL'); @@ -1514,12 +1793,11 @@ async function handleTestSub2ApiService() { elements.testSub2ApiServiceBtn.textContent = '测试中...'; try { - let result; - if (id && !apiKey) { - result = await api.post(`/sub2api-services/${id}/test`); - } else { - result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey }); - } + const payload = { api_url: apiUrl }; + if (id) payload.service_id = parseInt(id, 10); + if (apiKey) payload.api_key = apiKey; + + const result = await api.post('/sub2api-services/test-connection', payload); if (result.success) { toast.success(result.message); } else { diff --git a/templates/accounts.html b/templates/accounts.html index 32b67314..9e7eb5df 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -277,6 +277,26 @@

🔗 选择 Sub2API 服务

+ + + diff --git a/templates/settings.html b/templates/settings.html index e925ce3a..4242057c 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -349,6 +349,25 @@

添加 Sub2API 服务

+
+ + +
+
+ +
+
+
+
+ +
+

与账号页上传时选择的远端代理一致;仅在未显式选择代理时作为默认值。

+