Merge pull request #17 from Jack-261108/sub2api-remote-proxy-controls-20260324

Add remote proxy controls for Sub2API uploads
This commit is contained in:
凌晨瑞雪至 authored and GitHub committed 2026-03-25 09:54:04 +08:00
commit fa8f030843
12 files changed
+899 -28

No files matched your search

+167 -7
View File
@@ -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:
+3 -1
View File
@@ -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,
)
+1
View File
@@ -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)
+1
View File
@@ -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 已处理,此处兜底)
+19 -7
View File
@@ -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}
+6 -1
View File
@@ -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}")
+126 -4
View File
@@ -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
+165
View File
@@ -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;
+86
View File
@@ -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
View File
@@ -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 {
+20
View File
@@ -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">&times;</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>
+19
View File
@@ -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>