Merge pull request #17 from Jack-261108/sub2api-remote-proxy-controls-20260324
Add remote proxy controls for Sub2API uploads
This commit is contained in:
12 files changed
+899
-28
No files matched your search
@@ -3,25 +3,165 @@ Sub2API 账号上传功能
|
||||
将账号以 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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -111,6 +111,7 @@ class DatabaseSessionManager:
|
||||
("accounts", "subscription_at", "DATETIME"),
|
||||
("accounts", "cookies", "TEXT"),
|
||||
("proxies", "is_default", "BOOLEAN DEFAULT 0"),
|
||||
("sub2api_services", "default_remote_proxy_id", "INTEGER"),
|
||||
]
|
||||
|
||||
# 确保新表存在(create_tables 已处理,此处兜底)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -8,7 +8,11 @@ from pydantic import BaseModel
|
||||
|
||||
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 Sub2ApiServiceResponse(BaseModel):
|
||||
|
||||
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
|
||||
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 = '<div style="text-align:center;color:var(--text-muted);padding:12px;">该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传</div>';
|
||||
} else {
|
||||
listEl.innerHTML = proxies.map(proxy => `
|
||||
<div class="sub2api-service-item" data-id="${proxy.id}" style="
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
">
|
||||
<div>
|
||||
<div style="font-weight:500;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<span>#${proxy.id} ${escapeHtml(proxy.name)}</span>
|
||||
<span class="badge">${escapeHtml(String(proxy.protocol || '').toUpperCase())}</span>
|
||||
<span class="status-badge ${proxy.status === 'active' ? 'active' : ''}">${escapeHtml(proxy.status || 'inactive')}</span>
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);">${escapeHtml(proxy.host)}:${proxy.port}</div>
|
||||
</div>
|
||||
<span class="badge" style="background:var(--primary);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
|
||||
</div>
|
||||
`).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('上传成功');
|
||||
|
||||
+286
-8
@@ -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 '<span class="sub2api-remote-proxy-placeholder">不设置默认代理</span>';
|
||||
}
|
||||
|
||||
const protocol = option.protocol
|
||||
? `<span class="sub2api-remote-proxy-pill">${escapeHtml(option.protocol)}</span>`
|
||||
: '';
|
||||
const status = option.status
|
||||
? `<span class="status-badge ${getSub2ApiRemoteProxyStatusClass(option.status)}">${escapeHtml(option.status)}</span>`
|
||||
: '';
|
||||
const meta = option.meta
|
||||
? `<div class="sub2api-remote-proxy-summary-meta">${escapeHtml(option.meta)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="sub2api-remote-proxy-summary-title-row">
|
||||
<span class="sub2api-remote-proxy-summary-name">${escapeHtml(option.title)}</span>
|
||||
${protocol}
|
||||
${status}
|
||||
</div>
|
||||
${meta}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue) {
|
||||
const isSelected = option.value === selectedValue;
|
||||
const protocol = option.protocol
|
||||
? `<span class="sub2api-remote-proxy-pill">${escapeHtml(option.protocol)}</span>`
|
||||
: '';
|
||||
const status = option.status
|
||||
? `<span class="status-badge ${getSub2ApiRemoteProxyStatusClass(option.status)}">${escapeHtml(option.status)}</span>`
|
||||
: '';
|
||||
const meta = option.meta
|
||||
? `<div class="sub2api-remote-proxy-option-meta">${escapeHtml(option.meta)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<button type="button" class="sub2api-remote-proxy-option${isSelected ? ' selected' : ''}" data-proxy-value="${escapeHtml(option.value)}">
|
||||
<div class="sub2api-remote-proxy-option-main">
|
||||
<div class="sub2api-remote-proxy-option-title-row">
|
||||
<span class="sub2api-remote-proxy-option-name">${escapeHtml(option.title)}</span>
|
||||
${protocol}
|
||||
${status}
|
||||
</div>
|
||||
${meta}
|
||||
</div>
|
||||
<span class="sub2api-remote-proxy-option-check">${isSelected ? '已选' : '选择'}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -277,6 +277,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub2API 代理选择模态框 -->
|
||||
<div class="modal" id="sub2api-proxy-modal">
|
||||
<div class="modal-content" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h3>🌐 选择远端代理</h3>
|
||||
<button class="modal-close" id="close-sub2api-proxy-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: var(--text-muted); margin-bottom: 12px; font-size: 0.9rem;">选择目标 Sub2API 服务中已存在的远端代理,或选择不使用代理。</p>
|
||||
<div id="sub2api-proxy-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto;">
|
||||
<div style="text-align: center; color: var(--text-muted);">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button class="btn btn-secondary" id="sub2api-no-proxy-btn">不使用代理</button>
|
||||
<button class="btn btn-secondary" id="cancel-sub2api-proxy-modal-btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -349,6 +349,25 @@
|
||||
<label for="sub2api-service-key">API Key</label>
|
||||
<input type="password" id="sub2api-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sub2api-default-remote-proxy-id">默认远端代理</label>
|
||||
<input type="hidden" id="sub2api-default-remote-proxy-id" value="">
|
||||
<div class="sub2api-remote-proxy-row">
|
||||
<div class="sub2api-remote-proxy-picker">
|
||||
<button type="button" class="sub2api-remote-proxy-trigger" id="sub2api-remote-proxy-trigger" aria-expanded="false">
|
||||
<div class="sub2api-remote-proxy-trigger-content" id="sub2api-remote-proxy-trigger-content">
|
||||
<span class="sub2api-remote-proxy-placeholder">不设置默认代理</span>
|
||||
</div>
|
||||
<span class="sub2api-remote-proxy-trigger-arrow">▾</span>
|
||||
</button>
|
||||
<div class="sub2api-remote-proxy-dropdown" id="sub2api-remote-proxy-dropdown">
|
||||
<div class="sub2api-remote-proxy-options" id="sub2api-remote-proxy-options"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary sub2api-remote-proxy-btn" id="load-sub2api-remote-proxies-btn">加载代理</button>
|
||||
</div>
|
||||
<p class="sub2api-remote-proxy-hint">与账号页上传时选择的远端代理一致;仅在未显式选择代理时作为默认值。</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sub2api-service-priority">优先级</label>
|
||||
|
||||
Reference in new issue
Block a user