cxk commited on
Commit ·
f2e758b
1
Parent(s): 00662ac
feat(api): 添加ChatGPT API服务和CPA多池管理功能
Browse files- 实现FastAPI应用框架,支持CORS跨域和认证中间件
- 添加图像生成、聊天补全和响应创建等API端点
- 集成账户管理系统,支持账户创建、删除、刷新和更新操作
- 实现CPA多池配置管理,支持远程文件浏览和批量导入功能
- 添加账户限制监控器,自动检查和刷新受限账户
- 提供Web前端静态资源服务和版本信息接口
- 实现安全的密钥验证机制和账户数据脱敏处理
- 支持多线程并发处理图像生成请求
- 添加详细的API文档和错误处理机制
- services/api.py +41 -40
- services/backend_service.py +0 -0
- services/cpa_service.py +211 -238
- web/src/app/settings/page.tsx +361 -181
- web/src/lib/api.ts +29 -20
services/api.py
CHANGED
|
@@ -13,7 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
| 13 |
from services.account_service import account_service
|
| 14 |
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
| 16 |
-
from services.cpa_service import
|
| 17 |
|
| 18 |
from services.image_service import ImageGenerationError
|
| 19 |
from services.version import get_app_version
|
|
@@ -69,24 +69,21 @@ class ResponseCreateRequest(BaseModel):
|
|
| 69 |
tool_choice: object | None = None
|
| 70 |
stream: bool | None = None
|
| 71 |
|
| 72 |
-
class CPAConfigUpdateRequest(BaseModel):
|
| 73 |
-
base_url: str | None = None
|
| 74 |
-
|
| 75 |
-
secret_key: str | None = None
|
| 76 |
-
|
| 77 |
|
| 78 |
class CPAPoolCreateRequest(BaseModel):
|
| 79 |
name: str = ""
|
| 80 |
base_url: str = ""
|
| 81 |
secret_key: str = ""
|
| 82 |
-
enabled: bool = True
|
| 83 |
|
| 84 |
|
| 85 |
class CPAPoolUpdateRequest(BaseModel):
|
| 86 |
name: str | None = None
|
| 87 |
base_url: str | None = None
|
| 88 |
secret_key: str | None = None
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def build_model_item(model_id: str) -> dict[str, object]:
|
|
@@ -98,6 +95,20 @@ def build_model_item(model_id: str) -> dict[str, object]:
|
|
| 98 |
}
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def extract_bearer_token(authorization: str | None) -> str:
|
| 102 |
scheme, _, value = str(authorization or "").partition(" ")
|
| 103 |
if scheme.lower() != "bearer" or not value.strip():
|
|
@@ -283,7 +294,7 @@ def create_app() -> FastAPI:
|
|
| 283 |
@router.get("/api/cpa/pools")
|
| 284 |
async def list_cpa_pools(authorization: str | None = Header(default=None)):
|
| 285 |
require_auth_key(authorization)
|
| 286 |
-
return {"pools": cpa_config.list_pools()}
|
| 287 |
|
| 288 |
@router.post("/api/cpa/pools")
|
| 289 |
async def create_cpa_pool(
|
|
@@ -299,10 +310,8 @@ def create_app() -> FastAPI:
|
|
| 299 |
name=body.name,
|
| 300 |
base_url=body.base_url,
|
| 301 |
secret_key=body.secret_key,
|
| 302 |
-
enabled=body.enabled,
|
| 303 |
)
|
| 304 |
-
|
| 305 |
-
return {"pool": pool, "pools": cpa_config.list_pools()}
|
| 306 |
|
| 307 |
@router.post("/api/cpa/pools/{pool_id}")
|
| 308 |
async def update_cpa_pool(
|
|
@@ -314,8 +323,7 @@ def create_app() -> FastAPI:
|
|
| 314 |
pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
|
| 315 |
if pool is None:
|
| 316 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 317 |
-
|
| 318 |
-
return {"pool": pool, "pools": cpa_config.list_pools()}
|
| 319 |
|
| 320 |
@router.delete("/api/cpa/pools/{pool_id}")
|
| 321 |
async def delete_cpa_pool(
|
|
@@ -325,11 +333,10 @@ def create_app() -> FastAPI:
|
|
| 325 |
require_auth_key(authorization)
|
| 326 |
if not cpa_config.delete_pool(pool_id):
|
| 327 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 328 |
-
|
| 329 |
-
return {"pools": cpa_config.list_pools()}
|
| 330 |
|
| 331 |
-
@router.get("/api/cpa/pools/{pool_id}/
|
| 332 |
-
async def
|
| 333 |
pool_id: str,
|
| 334 |
authorization: str | None = Header(default=None),
|
| 335 |
):
|
|
@@ -337,38 +344,32 @@ def create_app() -> FastAPI:
|
|
| 337 |
pool = cpa_config.get_pool(pool_id)
|
| 338 |
if pool is None:
|
| 339 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 340 |
-
|
| 341 |
-
return
|
| 342 |
|
| 343 |
-
@router.post("/api/cpa/pools/{pool_id}/
|
| 344 |
-
async def
|
| 345 |
pool_id: str,
|
|
|
|
| 346 |
authorization: str | None = Header(default=None),
|
| 347 |
):
|
| 348 |
-
"""Pull tokens from a specific CPA pool and import into local account pool."""
|
| 349 |
require_auth_key(authorization)
|
| 350 |
pool = cpa_config.get_pool(pool_id)
|
| 351 |
if pool is None:
|
| 352 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
return {
|
| 359 |
-
**result,
|
| 360 |
-
"refreshed": refresh_result.get("refreshed", 0),
|
| 361 |
-
"errors": refresh_result.get("errors", []),
|
| 362 |
-
"items": refresh_result.get("items", result.get("items", [])),
|
| 363 |
-
}
|
| 364 |
|
| 365 |
-
@router.get("/api/cpa/
|
| 366 |
-
async def
|
| 367 |
require_auth_key(authorization)
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
return {"
|
| 372 |
|
| 373 |
app.include_router(router)
|
| 374 |
|
|
|
|
| 13 |
from services.account_service import account_service
|
| 14 |
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
| 16 |
+
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
| 17 |
|
| 18 |
from services.image_service import ImageGenerationError
|
| 19 |
from services.version import get_app_version
|
|
|
|
| 69 |
tool_choice: object | None = None
|
| 70 |
stream: bool | None = None
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
class CPAPoolCreateRequest(BaseModel):
|
| 74 |
name: str = ""
|
| 75 |
base_url: str = ""
|
| 76 |
secret_key: str = ""
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
class CPAPoolUpdateRequest(BaseModel):
|
| 80 |
name: str | None = None
|
| 81 |
base_url: str | None = None
|
| 82 |
secret_key: str | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class CPAImportRequest(BaseModel):
|
| 86 |
+
names: list[str] = Field(default_factory=list)
|
| 87 |
|
| 88 |
|
| 89 |
def build_model_item(model_id: str) -> dict[str, object]:
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
|
| 98 |
+
def sanitize_cpa_pool(pool: dict | None) -> dict | None:
|
| 99 |
+
if not isinstance(pool, dict):
|
| 100 |
+
return None
|
| 101 |
+
return {
|
| 102 |
+
key: value
|
| 103 |
+
for key, value in pool.items()
|
| 104 |
+
if key != "secret_key"
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
|
| 109 |
+
return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def extract_bearer_token(authorization: str | None) -> str:
|
| 113 |
scheme, _, value = str(authorization or "").partition(" ")
|
| 114 |
if scheme.lower() != "bearer" or not value.strip():
|
|
|
|
| 294 |
@router.get("/api/cpa/pools")
|
| 295 |
async def list_cpa_pools(authorization: str | None = Header(default=None)):
|
| 296 |
require_auth_key(authorization)
|
| 297 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 298 |
|
| 299 |
@router.post("/api/cpa/pools")
|
| 300 |
async def create_cpa_pool(
|
|
|
|
| 310 |
name=body.name,
|
| 311 |
base_url=body.base_url,
|
| 312 |
secret_key=body.secret_key,
|
|
|
|
| 313 |
)
|
| 314 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
|
|
|
| 315 |
|
| 316 |
@router.post("/api/cpa/pools/{pool_id}")
|
| 317 |
async def update_cpa_pool(
|
|
|
|
| 323 |
pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
|
| 324 |
if pool is None:
|
| 325 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 326 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
|
|
|
| 327 |
|
| 328 |
@router.delete("/api/cpa/pools/{pool_id}")
|
| 329 |
async def delete_cpa_pool(
|
|
|
|
| 333 |
require_auth_key(authorization)
|
| 334 |
if not cpa_config.delete_pool(pool_id):
|
| 335 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 336 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
|
|
|
| 337 |
|
| 338 |
+
@router.get("/api/cpa/pools/{pool_id}/files")
|
| 339 |
+
async def cpa_pool_files(
|
| 340 |
pool_id: str,
|
| 341 |
authorization: str | None = Header(default=None),
|
| 342 |
):
|
|
|
|
| 344 |
pool = cpa_config.get_pool(pool_id)
|
| 345 |
if pool is None:
|
| 346 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 347 |
+
files = await run_in_threadpool(list_remote_files, pool)
|
| 348 |
+
return {"pool_id": pool_id, "files": files}
|
| 349 |
|
| 350 |
+
@router.post("/api/cpa/pools/{pool_id}/import")
|
| 351 |
+
async def cpa_pool_import(
|
| 352 |
pool_id: str,
|
| 353 |
+
body: CPAImportRequest,
|
| 354 |
authorization: str | None = Header(default=None),
|
| 355 |
):
|
|
|
|
| 356 |
require_auth_key(authorization)
|
| 357 |
pool = cpa_config.get_pool(pool_id)
|
| 358 |
if pool is None:
|
| 359 |
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 360 |
+
try:
|
| 361 |
+
job = cpa_import_service.start_import(pool, body.names)
|
| 362 |
+
except ValueError as exc:
|
| 363 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 364 |
+
return {"import_job": job}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
@router.get("/api/cpa/pools/{pool_id}/import")
|
| 367 |
+
async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
|
| 368 |
require_auth_key(authorization)
|
| 369 |
+
pool = cpa_config.get_pool(pool_id)
|
| 370 |
+
if pool is None:
|
| 371 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 372 |
+
return {"import_job": pool.get("import_job")}
|
| 373 |
|
| 374 |
app.include_router(router)
|
| 375 |
|
services/backend_service.py
DELETED
|
File without changes
|
services/cpa_service.py
CHANGED
|
@@ -1,66 +1,84 @@
|
|
| 1 |
-
"""CLIProxyAPI integration
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
-
import
|
| 7 |
import uuid
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
from threading import Lock
|
| 11 |
-
from typing import Any
|
| 12 |
|
| 13 |
from curl_cffi.requests import Session
|
| 14 |
|
| 15 |
-
from services.
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
|
| 19 |
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _new_pool_id() -> str:
|
| 24 |
return uuid.uuid4().hex[:12]
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def _normalize_pool(raw: dict) -> dict:
|
| 28 |
return {
|
| 29 |
-
"id": str(raw.get("id") or
|
| 30 |
"name": str(raw.get("name") or "").strip(),
|
| 31 |
"base_url": str(raw.get("base_url") or "").strip(),
|
| 32 |
"secret_key": str(raw.get("secret_key") or "").strip(),
|
| 33 |
-
"
|
| 34 |
}
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
return
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
# ── Persisted multi-pool config ─────────────────────────────────────
|
| 42 |
|
| 43 |
class CPAConfig:
|
| 44 |
-
"""Manages a list of CPA pool entries persisted to ``cpa_config.json``."""
|
| 45 |
-
|
| 46 |
def __init__(self, store_file: Path):
|
| 47 |
self._store_file = store_file
|
| 48 |
self._lock = Lock()
|
| 49 |
self._pools: list[dict] = self._load()
|
| 50 |
|
| 51 |
-
# -- persistence ---------------------------------------------------
|
| 52 |
-
|
| 53 |
def _load(self) -> list[dict]:
|
| 54 |
if not self._store_file.exists():
|
| 55 |
return []
|
| 56 |
try:
|
| 57 |
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
| 58 |
-
# Migrate from old single-pool format
|
| 59 |
if isinstance(raw, dict) and "base_url" in raw:
|
| 60 |
pool = _normalize_pool(raw)
|
| 61 |
-
if pool["base_url"]
|
| 62 |
-
return [pool]
|
| 63 |
-
return []
|
| 64 |
if isinstance(raw, list):
|
| 65 |
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
|
| 66 |
except Exception:
|
|
@@ -69,32 +87,21 @@ class CPAConfig:
|
|
| 69 |
|
| 70 |
def _save(self) -> None:
|
| 71 |
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 72 |
-
self._store_file.write_text(
|
| 73 |
-
json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n",
|
| 74 |
-
encoding="utf-8",
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
# -- public API ----------------------------------------------------
|
| 78 |
|
| 79 |
def list_pools(self) -> list[dict]:
|
| 80 |
with self._lock:
|
| 81 |
-
return [dict(
|
| 82 |
|
| 83 |
def get_pool(self, pool_id: str) -> dict | None:
|
| 84 |
with self._lock:
|
| 85 |
-
for
|
| 86 |
-
if
|
| 87 |
-
return dict(
|
| 88 |
return None
|
| 89 |
|
| 90 |
-
def add_pool(self, name: str, base_url: str, secret_key: str
|
| 91 |
-
pool = _normalize_pool({
|
| 92 |
-
"id": _new_pool_id(),
|
| 93 |
-
"name": name,
|
| 94 |
-
"base_url": base_url,
|
| 95 |
-
"secret_key": secret_key,
|
| 96 |
-
"enabled": enabled,
|
| 97 |
-
})
|
| 98 |
with self._lock:
|
| 99 |
self._pools.append(pool)
|
| 100 |
self._save()
|
|
@@ -102,240 +109,206 @@ class CPAConfig:
|
|
| 102 |
|
| 103 |
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
|
| 104 |
with self._lock:
|
| 105 |
-
for
|
| 106 |
-
if
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
| 111 |
return None
|
| 112 |
|
| 113 |
def delete_pool(self, pool_id: str) -> bool:
|
| 114 |
with self._lock:
|
| 115 |
before = len(self._pools)
|
| 116 |
-
self._pools = [
|
| 117 |
if len(self._pools) < before:
|
| 118 |
self._save()
|
| 119 |
return True
|
| 120 |
return False
|
| 121 |
|
| 122 |
-
def
|
| 123 |
with self._lock:
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
def has_usable(self) -> bool:
|
| 128 |
with self._lock:
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _management_headers(secret_key: str) -> dict[str, str]:
|
| 135 |
-
return {
|
| 136 |
-
"Authorization": f"Bearer {secret_key}",
|
| 137 |
-
"Accept": "application/json",
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def _extract_access_token(auth_file: dict[str, Any]) -> str | None:
|
| 142 |
-
for key in ("access_token", "token", "accessToken", "access-token"):
|
| 143 |
-
value = str(auth_file.get(key) or "").strip()
|
| 144 |
-
if value:
|
| 145 |
-
return value
|
| 146 |
-
for wrapper_key in ("data", "content", "credential", "auth", "credentials"):
|
| 147 |
-
nested = auth_file.get(wrapper_key)
|
| 148 |
-
if isinstance(nested, dict):
|
| 149 |
-
for key in ("access_token", "token", "accessToken", "access-token"):
|
| 150 |
-
value = str(nested.get(key) or "").strip()
|
| 151 |
-
if value:
|
| 152 |
-
return value
|
| 153 |
-
for value in auth_file.values():
|
| 154 |
-
if isinstance(value, str) and value.strip().startswith("eyJ") and len(value.strip()) > 100:
|
| 155 |
-
return value.strip()
|
| 156 |
-
return None
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
def _resolve_file_list(payload: Any) -> list[dict]:
|
| 160 |
-
if isinstance(payload, list):
|
| 161 |
-
return [item for item in payload if isinstance(item, dict)]
|
| 162 |
-
if isinstance(payload, dict):
|
| 163 |
-
for key in ("files", "auth_files", "auth-files", "data", "items"):
|
| 164 |
-
candidate = payload.get(key)
|
| 165 |
-
if isinstance(candidate, list):
|
| 166 |
-
return [item for item in candidate if isinstance(item, dict)]
|
| 167 |
-
return [payload]
|
| 168 |
-
return []
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def _fetch_file_detail(session: Session, base_url: str, secret_key: str, file_name: str) -> dict | None:
|
| 172 |
-
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
|
| 173 |
-
try:
|
| 174 |
-
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=15)
|
| 175 |
-
if not response.ok:
|
| 176 |
-
return None
|
| 177 |
-
data = response.json()
|
| 178 |
-
return data if isinstance(data, dict) else None
|
| 179 |
-
except Exception:
|
| 180 |
return None
|
| 181 |
|
| 182 |
|
| 183 |
-
def
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
secret_key = pool.get("secret_key") or ""
|
| 187 |
if not base_url or not secret_key:
|
| 188 |
return []
|
| 189 |
|
| 190 |
-
pool_name = pool.get("name") or pool.get("id") or "?"
|
| 191 |
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
session = Session(verify=config.tls_verify)
|
| 195 |
try:
|
| 196 |
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
|
| 197 |
if not response.ok:
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
payload = response.json()
|
| 201 |
except Exception as exc:
|
| 202 |
-
|
| 203 |
-
|
|
|
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
e for e in file_entries
|
| 208 |
-
if not e.get("disabled") and not e.get("unavailable")
|
| 209 |
-
and e.get("status") in (None, "", "active")
|
| 210 |
-
and e.get("type") in (None, "", "codex")
|
| 211 |
-
]
|
| 212 |
-
print(f"[cpa-service] [{pool_name}] {len(active_entries)} active codex entries")
|
| 213 |
-
|
| 214 |
-
tokens: list[str] = []
|
| 215 |
-
seen: set[str] = set()
|
| 216 |
-
need_download: list[dict] = []
|
| 217 |
-
|
| 218 |
-
for entry in active_entries:
|
| 219 |
-
token = _extract_access_token(entry)
|
| 220 |
-
if token and token not in seen:
|
| 221 |
-
seen.add(token)
|
| 222 |
-
tokens.append(token)
|
| 223 |
-
else:
|
| 224 |
-
need_download.append(entry)
|
| 225 |
-
|
| 226 |
-
if need_download:
|
| 227 |
-
max_workers = min(10, len(need_download))
|
| 228 |
-
try:
|
| 229 |
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 230 |
-
future_map = {}
|
| 231 |
-
for entry in need_download:
|
| 232 |
-
file_name = str(entry.get("name") or entry.get("id") or "").strip()
|
| 233 |
-
if not file_name:
|
| 234 |
-
continue
|
| 235 |
-
future = executor.submit(_fetch_file_detail, session, base_url, secret_key, file_name)
|
| 236 |
-
future_map[future] = file_name
|
| 237 |
-
|
| 238 |
-
for future in as_completed(future_map):
|
| 239 |
-
detail = future.result()
|
| 240 |
-
if detail is None:
|
| 241 |
-
continue
|
| 242 |
-
content = detail.get("content")
|
| 243 |
-
if isinstance(content, str):
|
| 244 |
-
try:
|
| 245 |
-
parsed = json.loads(content)
|
| 246 |
-
if isinstance(parsed, dict):
|
| 247 |
-
detail = {**detail, **parsed}
|
| 248 |
-
except Exception:
|
| 249 |
-
pass
|
| 250 |
-
token = _extract_access_token(detail)
|
| 251 |
-
if token and token not in seen:
|
| 252 |
-
seen.add(token)
|
| 253 |
-
tokens.append(token)
|
| 254 |
-
except Exception as exc:
|
| 255 |
-
print(f"[cpa-service] [{pool_name}] concurrent fetch error: {exc}")
|
| 256 |
-
|
| 257 |
-
session.close()
|
| 258 |
-
print(f"[cpa-service] [{pool_name}] extracted {len(tokens)} token(s)")
|
| 259 |
-
return tokens
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
def fetch_pool_status(pool: dict) -> dict:
|
| 263 |
-
"""Test a single pool and return status info."""
|
| 264 |
-
tokens = fetch_tokens_for_pool(pool)
|
| 265 |
-
return {"pool_id": pool["id"], "tokens": len(tokens)}
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
# ── Aggregated CPA service ──────────────────────────────────────────
|
| 269 |
-
|
| 270 |
-
class CPAService:
|
| 271 |
-
"""Aggregates tokens from all usable CPA pools with caching."""
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def __init__(self, cpa_config: CPAConfig):
|
| 274 |
self._config = cpa_config
|
| 275 |
-
self._lock = Lock()
|
| 276 |
-
self._tokens: list[str] = []
|
| 277 |
-
self._index = 0
|
| 278 |
-
self._last_refresh: float = 0
|
| 279 |
-
self._cache_ttl = 300
|
| 280 |
-
|
| 281 |
-
@property
|
| 282 |
-
def enabled(self) -> bool:
|
| 283 |
-
return self._config.has_usable
|
| 284 |
-
|
| 285 |
-
def fetch_all_tokens(self) -> list[str]:
|
| 286 |
-
"""Fetch tokens from all usable pools concurrently."""
|
| 287 |
-
pools = self._config.usable_pools()
|
| 288 |
-
if not pools:
|
| 289 |
-
return []
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 296 |
-
future_map = {executor.submit(
|
| 297 |
for future in as_completed(future_map):
|
|
|
|
| 298 |
try:
|
| 299 |
-
|
| 300 |
-
for token in tokens:
|
| 301 |
-
if token not in seen:
|
| 302 |
-
seen.add(token)
|
| 303 |
-
all_tokens.append(token)
|
| 304 |
except Exception as exc:
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
-
# ── Singletons ──────────────────────────────────────────────────────
|
| 335 |
|
| 336 |
cpa_config = CPAConfig(CPA_CONFIG_FILE)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
if cpa_config.has_usable:
|
| 340 |
-
pools = cpa_config.usable_pools()
|
| 341 |
-
print(f"[cpa-service] {len(pools)} usable pool(s) configured")
|
|
|
|
| 1 |
+
"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
+
import threading
|
| 7 |
import uuid
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
from pathlib import Path
|
| 11 |
from threading import Lock
|
|
|
|
| 12 |
|
| 13 |
from curl_cffi.requests import Session
|
| 14 |
|
| 15 |
+
from services.account_service import account_service
|
| 16 |
+
from services.config import DATA_DIR
|
| 17 |
|
| 18 |
|
| 19 |
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
|
| 20 |
|
| 21 |
|
| 22 |
+
def _new_id() -> str:
|
|
|
|
|
|
|
| 23 |
return uuid.uuid4().hex[:12]
|
| 24 |
|
| 25 |
|
| 26 |
+
def _now_iso() -> str:
|
| 27 |
+
return datetime.now(timezone.utc).isoformat()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
|
| 31 |
+
if not isinstance(raw, dict):
|
| 32 |
+
return None
|
| 33 |
+
status = str(raw.get("status") or "failed").strip() or "failed"
|
| 34 |
+
if fail_unfinished and status in {"pending", "running"}:
|
| 35 |
+
status = "failed"
|
| 36 |
+
return {
|
| 37 |
+
"job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
|
| 38 |
+
"status": status,
|
| 39 |
+
"created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 40 |
+
"updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 41 |
+
"total": int(raw.get("total") or 0),
|
| 42 |
+
"completed": int(raw.get("completed") or 0),
|
| 43 |
+
"added": int(raw.get("added") or 0),
|
| 44 |
+
"skipped": int(raw.get("skipped") or 0),
|
| 45 |
+
"refreshed": int(raw.get("refreshed") or 0),
|
| 46 |
+
"failed": int(raw.get("failed") or 0),
|
| 47 |
+
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def _normalize_pool(raw: dict) -> dict:
|
| 52 |
return {
|
| 53 |
+
"id": str(raw.get("id") or _new_id()).strip(),
|
| 54 |
"name": str(raw.get("name") or "").strip(),
|
| 55 |
"base_url": str(raw.get("base_url") or "").strip(),
|
| 56 |
"secret_key": str(raw.get("secret_key") or "").strip(),
|
| 57 |
+
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
|
| 58 |
}
|
| 59 |
|
| 60 |
|
| 61 |
+
def _management_headers(secret_key: str) -> dict[str, str]:
|
| 62 |
+
return {
|
| 63 |
+
"Authorization": f"Bearer {secret_key}",
|
| 64 |
+
"Accept": "application/json",
|
| 65 |
+
}
|
| 66 |
|
|
|
|
| 67 |
|
| 68 |
class CPAConfig:
|
|
|
|
|
|
|
| 69 |
def __init__(self, store_file: Path):
|
| 70 |
self._store_file = store_file
|
| 71 |
self._lock = Lock()
|
| 72 |
self._pools: list[dict] = self._load()
|
| 73 |
|
|
|
|
|
|
|
| 74 |
def _load(self) -> list[dict]:
|
| 75 |
if not self._store_file.exists():
|
| 76 |
return []
|
| 77 |
try:
|
| 78 |
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
|
|
|
| 79 |
if isinstance(raw, dict) and "base_url" in raw:
|
| 80 |
pool = _normalize_pool(raw)
|
| 81 |
+
return [pool] if pool["base_url"] else []
|
|
|
|
|
|
|
| 82 |
if isinstance(raw, list):
|
| 83 |
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
|
| 84 |
except Exception:
|
|
|
|
| 87 |
|
| 88 |
def _save(self) -> None:
|
| 89 |
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
def list_pools(self) -> list[dict]:
|
| 93 |
with self._lock:
|
| 94 |
+
return [dict(pool) for pool in self._pools]
|
| 95 |
|
| 96 |
def get_pool(self, pool_id: str) -> dict | None:
|
| 97 |
with self._lock:
|
| 98 |
+
for pool in self._pools:
|
| 99 |
+
if pool["id"] == pool_id:
|
| 100 |
+
return dict(pool)
|
| 101 |
return None
|
| 102 |
|
| 103 |
+
def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
|
| 104 |
+
pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
with self._lock:
|
| 106 |
self._pools.append(pool)
|
| 107 |
self._save()
|
|
|
|
| 109 |
|
| 110 |
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
|
| 111 |
with self._lock:
|
| 112 |
+
for index, pool in enumerate(self._pools):
|
| 113 |
+
if pool["id"] != pool_id:
|
| 114 |
+
continue
|
| 115 |
+
merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
|
| 116 |
+
self._pools[index] = _normalize_pool(merged)
|
| 117 |
+
self._save()
|
| 118 |
+
return dict(self._pools[index])
|
| 119 |
return None
|
| 120 |
|
| 121 |
def delete_pool(self, pool_id: str) -> bool:
|
| 122 |
with self._lock:
|
| 123 |
before = len(self._pools)
|
| 124 |
+
self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
|
| 125 |
if len(self._pools) < before:
|
| 126 |
self._save()
|
| 127 |
return True
|
| 128 |
return False
|
| 129 |
|
| 130 |
+
def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
|
| 131 |
with self._lock:
|
| 132 |
+
for index, pool in enumerate(self._pools):
|
| 133 |
+
if pool["id"] != pool_id:
|
| 134 |
+
continue
|
| 135 |
+
next_pool = dict(pool)
|
| 136 |
+
next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
|
| 137 |
+
self._pools[index] = next_pool
|
| 138 |
+
self._save()
|
| 139 |
+
return dict(next_pool)
|
| 140 |
+
return None
|
| 141 |
|
| 142 |
+
def get_import_job(self, pool_id: str) -> dict | None:
|
|
|
|
| 143 |
with self._lock:
|
| 144 |
+
for pool in self._pools:
|
| 145 |
+
if pool["id"] == pool_id:
|
| 146 |
+
job = pool.get("import_job")
|
| 147 |
+
return dict(job) if isinstance(job, dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
return None
|
| 149 |
|
| 150 |
|
| 151 |
+
def list_remote_files(pool: dict) -> list[dict]:
|
| 152 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 153 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
|
|
|
| 154 |
if not base_url or not secret_key:
|
| 155 |
return []
|
| 156 |
|
|
|
|
| 157 |
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
|
| 158 |
+
session = Session(verify=True)
|
|
|
|
|
|
|
| 159 |
try:
|
| 160 |
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
|
| 161 |
if not response.ok:
|
| 162 |
+
raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
|
| 163 |
+
payload = response.json()
|
| 164 |
+
finally:
|
| 165 |
+
session.close()
|
| 166 |
+
|
| 167 |
+
files = payload.get("files") if isinstance(payload, dict) else None
|
| 168 |
+
if not isinstance(files, list):
|
| 169 |
+
raise RuntimeError("remote list payload is invalid")
|
| 170 |
+
|
| 171 |
+
items: list[dict] = []
|
| 172 |
+
for item in files:
|
| 173 |
+
if not isinstance(item, dict):
|
| 174 |
+
continue
|
| 175 |
+
name = str(item.get("name") or "").strip()
|
| 176 |
+
email = str(item.get("email") or item.get("account") or "").strip()
|
| 177 |
+
if not name:
|
| 178 |
+
continue
|
| 179 |
+
items.append({"name": name, "email": email})
|
| 180 |
+
return items
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
|
| 184 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 185 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
| 186 |
+
file_name = str(file_name or "").strip()
|
| 187 |
+
if not base_url or not secret_key or not file_name:
|
| 188 |
+
return None, "invalid request"
|
| 189 |
+
|
| 190 |
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
|
| 191 |
+
session = Session(verify=True)
|
| 192 |
+
try:
|
| 193 |
+
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
|
| 194 |
+
if not response.ok:
|
| 195 |
+
return None, f"HTTP {response.status_code}"
|
| 196 |
payload = response.json()
|
| 197 |
except Exception as exc:
|
| 198 |
+
return None, str(exc)
|
| 199 |
+
finally:
|
| 200 |
+
session.close()
|
| 201 |
|
| 202 |
+
if not isinstance(payload, dict):
|
| 203 |
+
return None, "invalid payload"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
access_token = str(payload.get("access_token") or "").strip()
|
| 206 |
+
if not access_token:
|
| 207 |
+
return None, "missing access_token"
|
| 208 |
+
return access_token, None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class CPAImportService:
|
| 212 |
def __init__(self, cpa_config: CPAConfig):
|
| 213 |
self._config = cpa_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
+
def start_import(self, pool: dict, selected_files: list[str]) -> dict:
|
| 216 |
+
names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
|
| 217 |
+
if not names:
|
| 218 |
+
raise ValueError("selected files is required")
|
| 219 |
+
|
| 220 |
+
pool_id = str(pool.get("id") or "").strip()
|
| 221 |
+
job = {
|
| 222 |
+
"job_id": uuid.uuid4().hex,
|
| 223 |
+
"status": "pending",
|
| 224 |
+
"created_at": _now_iso(),
|
| 225 |
+
"updated_at": _now_iso(),
|
| 226 |
+
"total": len(names),
|
| 227 |
+
"completed": 0,
|
| 228 |
+
"added": 0,
|
| 229 |
+
"skipped": 0,
|
| 230 |
+
"refreshed": 0,
|
| 231 |
+
"failed": 0,
|
| 232 |
+
"errors": [],
|
| 233 |
+
}
|
| 234 |
+
saved_pool = self._config.set_import_job(pool_id, job)
|
| 235 |
+
if saved_pool is None:
|
| 236 |
+
raise ValueError("pool not found")
|
| 237 |
+
|
| 238 |
+
thread = threading.Thread(
|
| 239 |
+
target=self._run_import,
|
| 240 |
+
args=(pool_id, pool, names),
|
| 241 |
+
name=f"cpa-import-{pool_id}",
|
| 242 |
+
daemon=True,
|
| 243 |
+
)
|
| 244 |
+
thread.start()
|
| 245 |
+
return dict(saved_pool.get("import_job") or job)
|
| 246 |
|
| 247 |
+
def _update_job(self, pool_id: str, **updates) -> dict | None:
|
| 248 |
+
current = self._config.get_import_job(pool_id)
|
| 249 |
+
if current is None:
|
| 250 |
+
return None
|
| 251 |
+
next_job = {**current, **updates, "updated_at": _now_iso()}
|
| 252 |
+
pool = self._config.set_import_job(pool_id, next_job)
|
| 253 |
+
if pool is None:
|
| 254 |
+
return None
|
| 255 |
+
job = pool.get("import_job")
|
| 256 |
+
return dict(job) if isinstance(job, dict) else None
|
| 257 |
+
|
| 258 |
+
def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
|
| 259 |
+
current = self._config.get_import_job(pool_id)
|
| 260 |
+
if current is None:
|
| 261 |
+
return
|
| 262 |
+
errors = list(current.get("errors") or [])
|
| 263 |
+
errors.append({"name": file_name, "error": message})
|
| 264 |
+
self._update_job(pool_id, errors=errors, failed=len(errors))
|
| 265 |
+
|
| 266 |
+
def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
|
| 267 |
+
self._update_job(pool_id, status="running")
|
| 268 |
+
|
| 269 |
+
tokens: list[str] = []
|
| 270 |
+
max_workers = min(16, max(1, len(names)))
|
| 271 |
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 272 |
+
future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
|
| 273 |
for future in as_completed(future_map):
|
| 274 |
+
file_name = future_map[future]
|
| 275 |
try:
|
| 276 |
+
token, error = future.result()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
except Exception as exc:
|
| 278 |
+
token, error = None, str(exc)
|
| 279 |
+
|
| 280 |
+
if token:
|
| 281 |
+
tokens.append(token)
|
| 282 |
+
else:
|
| 283 |
+
self._append_error(pool_id, file_name, error or "unknown error")
|
| 284 |
+
|
| 285 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 286 |
+
failed = len(current.get("errors") or [])
|
| 287 |
+
self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
|
| 288 |
+
|
| 289 |
+
if not tokens:
|
| 290 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 291 |
+
self._update_job(
|
| 292 |
+
pool_id,
|
| 293 |
+
status="failed",
|
| 294 |
+
completed=int(current.get("total") or 0),
|
| 295 |
+
failed=len(current.get("errors") or []),
|
| 296 |
+
)
|
| 297 |
+
return
|
| 298 |
+
|
| 299 |
+
add_result = account_service.add_accounts(tokens)
|
| 300 |
+
refresh_result = account_service.refresh_accounts(tokens)
|
| 301 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 302 |
+
self._update_job(
|
| 303 |
+
pool_id,
|
| 304 |
+
status="completed",
|
| 305 |
+
completed=len(names),
|
| 306 |
+
added=int(add_result.get("added") or 0),
|
| 307 |
+
skipped=int(add_result.get("skipped") or 0),
|
| 308 |
+
refreshed=int(refresh_result.get("refreshed") or 0),
|
| 309 |
+
failed=len(current.get("errors") or []),
|
| 310 |
+
)
|
| 311 |
|
|
|
|
| 312 |
|
| 313 |
cpa_config = CPAConfig(CPA_CONFIG_FILE)
|
| 314 |
+
cpa_import_service = CPAImportService(cpa_config)
|
|
|
|
|
|
|
|
|
|
|
|
web/src/app/settings/page.tsx
CHANGED
|
@@ -1,18 +1,16 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useEffect, useRef, useState } from "react";
|
| 4 |
import {
|
| 5 |
-
CheckCircle2,
|
| 6 |
Eye,
|
| 7 |
EyeOff,
|
|
|
|
| 8 |
Link2,
|
| 9 |
LoaderCircle,
|
| 10 |
Pencil,
|
| 11 |
Plus,
|
| 12 |
-
Power,
|
| 13 |
-
PowerOff,
|
| 14 |
-
RefreshCw,
|
| 15 |
Save,
|
|
|
|
| 16 |
ServerCog,
|
| 17 |
Trash2,
|
| 18 |
Unplug,
|
|
@@ -22,6 +20,7 @@ import { toast } from "sonner";
|
|
| 22 |
import { Badge } from "@/components/ui/badge";
|
| 23 |
import { Button } from "@/components/ui/button";
|
| 24 |
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
| 25 |
import {
|
| 26 |
Dialog,
|
| 27 |
DialogContent,
|
|
@@ -31,26 +30,50 @@ import {
|
|
| 31 |
DialogTitle,
|
| 32 |
} from "@/components/ui/dialog";
|
| 33 |
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
import {
|
| 35 |
createCPAPool,
|
| 36 |
deleteCPAPool,
|
| 37 |
-
|
| 38 |
fetchCPAPools,
|
| 39 |
-
|
| 40 |
updateCPAPool,
|
| 41 |
type CPAPool,
|
|
|
|
| 42 |
} from "@/lib/api";
|
| 43 |
-
import { cn } from "@/lib/utils";
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
export default function SettingsPage() {
|
| 48 |
const didLoadRef = useRef(false);
|
|
|
|
|
|
|
| 49 |
const [pools, setPools] = useState<CPAPool[]>([]);
|
| 50 |
-
const [poolStatuses, setPoolStatuses] = useState<Record<string, PoolStatus>>({});
|
| 51 |
const [isLoading, setIsLoading] = useState(true);
|
| 52 |
|
| 53 |
-
// Dialog state
|
| 54 |
const [dialogOpen, setDialogOpen] = useState(false);
|
| 55 |
const [editingPool, setEditingPool] = useState<CPAPool | null>(null);
|
| 56 |
const [formName, setFormName] = useState("");
|
|
@@ -59,30 +82,73 @@ export default function SettingsPage() {
|
|
| 59 |
const [showSecret, setShowSecret] = useState(false);
|
| 60 |
const [isSaving, setIsSaving] = useState(false);
|
| 61 |
|
| 62 |
-
// Per-pool loading states
|
| 63 |
-
const [testingId, setTestingId] = useState<string | null>(null);
|
| 64 |
-
const [syncingId, setSyncingId] = useState<string | null>(null);
|
| 65 |
const [deletingId, setDeletingId] = useState<string | null>(null);
|
| 66 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
const loadPools = async () => {
|
| 69 |
setIsLoading(true);
|
| 70 |
try {
|
| 71 |
-
const
|
| 72 |
-
setPools(
|
| 73 |
} catch (error) {
|
| 74 |
-
toast.error(error instanceof Error ? error.message : "加载 CPA
|
| 75 |
} finally {
|
| 76 |
setIsLoading(false);
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
| 80 |
useEffect(() => {
|
| 81 |
-
if (didLoadRef.current)
|
|
|
|
|
|
|
| 82 |
didLoadRef.current = true;
|
| 83 |
void loadPools();
|
| 84 |
}, []);
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
const openAddDialog = () => {
|
| 87 |
setEditingPool(null);
|
| 88 |
setFormName("");
|
|
@@ -96,7 +162,7 @@ export default function SettingsPage() {
|
|
| 96 |
setEditingPool(pool);
|
| 97 |
setFormName(pool.name);
|
| 98 |
setFormBaseUrl(pool.base_url);
|
| 99 |
-
setFormSecretKey(
|
| 100 |
setShowSecret(false);
|
| 101 |
setDialogOpen(true);
|
| 102 |
};
|
|
@@ -106,20 +172,21 @@ export default function SettingsPage() {
|
|
| 106 |
toast.error("请输入 CPA 地址");
|
| 107 |
return;
|
| 108 |
}
|
| 109 |
-
if (!formSecretKey.trim()) {
|
| 110 |
toast.error("请输入 Secret Key");
|
| 111 |
return;
|
| 112 |
}
|
|
|
|
| 113 |
setIsSaving(true);
|
| 114 |
try {
|
| 115 |
if (editingPool) {
|
| 116 |
const data = await updateCPAPool(editingPool.id, {
|
| 117 |
name: formName.trim(),
|
| 118 |
base_url: formBaseUrl.trim(),
|
| 119 |
-
secret_key: formSecretKey.trim(),
|
| 120 |
});
|
| 121 |
setPools(data.pools);
|
| 122 |
-
toast.success("
|
| 123 |
} else {
|
| 124 |
const data = await createCPAPool({
|
| 125 |
name: formName.trim(),
|
|
@@ -127,7 +194,7 @@ export default function SettingsPage() {
|
|
| 127 |
secret_key: formSecretKey.trim(),
|
| 128 |
});
|
| 129 |
setPools(data.pools);
|
| 130 |
-
toast.success("
|
| 131 |
}
|
| 132 |
setDialogOpen(false);
|
| 133 |
} catch (error) {
|
|
@@ -137,25 +204,12 @@ export default function SettingsPage() {
|
|
| 137 |
}
|
| 138 |
};
|
| 139 |
|
| 140 |
-
const handleToggle = async (pool: CPAPool) => {
|
| 141 |
-
setTogglingId(pool.id);
|
| 142 |
-
try {
|
| 143 |
-
const data = await updateCPAPool(pool.id, { enabled: !pool.enabled });
|
| 144 |
-
setPools(data.pools);
|
| 145 |
-
toast.success(pool.enabled ? "已禁用" : "已启用");
|
| 146 |
-
} catch (error) {
|
| 147 |
-
toast.error(error instanceof Error ? error.message : "操作失败");
|
| 148 |
-
} finally {
|
| 149 |
-
setTogglingId(null);
|
| 150 |
-
}
|
| 151 |
-
};
|
| 152 |
-
|
| 153 |
const handleDelete = async (pool: CPAPool) => {
|
| 154 |
setDeletingId(pool.id);
|
| 155 |
try {
|
| 156 |
const data = await deleteCPAPool(pool.id);
|
| 157 |
setPools(data.pools);
|
| 158 |
-
toast.success("
|
| 159 |
} catch (error) {
|
| 160 |
toast.error(error instanceof Error ? error.message : "删除失败");
|
| 161 |
} finally {
|
|
@@ -163,43 +217,83 @@ export default function SettingsPage() {
|
|
| 163 |
}
|
| 164 |
};
|
| 165 |
|
| 166 |
-
const
|
| 167 |
-
|
| 168 |
try {
|
| 169 |
-
const
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
} catch (error) {
|
| 177 |
-
toast.error(error instanceof Error ? error.message : "
|
| 178 |
} finally {
|
| 179 |
-
|
| 180 |
}
|
| 181 |
};
|
| 182 |
|
| 183 |
-
const
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
} catch (error) {
|
| 195 |
-
toast.error(error instanceof Error ? error.message : "
|
| 196 |
} finally {
|
| 197 |
-
|
| 198 |
}
|
| 199 |
};
|
| 200 |
|
| 201 |
-
const enabledCount = pools.filter((p) => p.enabled).length;
|
| 202 |
-
|
| 203 |
return (
|
| 204 |
<>
|
| 205 |
<section className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
@@ -218,27 +312,15 @@ export default function SettingsPage() {
|
|
| 218 |
<ServerCog className="size-5 text-stone-600" />
|
| 219 |
</div>
|
| 220 |
<div>
|
| 221 |
-
<h2 className="text-lg font-semibold tracking-tight">CPA
|
| 222 |
-
<p className="text-sm text-stone-500">
|
| 223 |
-
连接多个 CLIProxyAPI 实例,自动获取 access_token 用于生图
|
| 224 |
-
</p>
|
| 225 |
</div>
|
| 226 |
</div>
|
| 227 |
<div className="flex items-center gap-2">
|
| 228 |
-
{pools.length > 0
|
| 229 |
-
|
| 230 |
-
variant={enabledCount > 0 ? "success" : "secondary"}
|
| 231 |
-
className="rounded-md px-2.5 py-1"
|
| 232 |
-
>
|
| 233 |
-
{enabledCount}/{pools.length} 启用
|
| 234 |
-
</Badge>
|
| 235 |
-
)}
|
| 236 |
-
<Button
|
| 237 |
-
className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
|
| 238 |
-
onClick={openAddDialog}
|
| 239 |
-
>
|
| 240 |
<Plus className="size-4" />
|
| 241 |
-
添加
|
| 242 |
</Button>
|
| 243 |
</div>
|
| 244 |
</div>
|
|
@@ -251,63 +333,23 @@ export default function SettingsPage() {
|
|
| 251 |
<div className="flex flex-col items-center justify-center gap-3 rounded-xl bg-stone-50 px-6 py-10 text-center">
|
| 252 |
<ServerCog className="size-8 text-stone-300" />
|
| 253 |
<div className="space-y-1">
|
| 254 |
-
<p className="text-sm font-medium text-stone-600">暂无 CPA
|
| 255 |
-
<p className="text-sm text-stone-400">点击「添加
|
| 256 |
</div>
|
| 257 |
</div>
|
| 258 |
) : (
|
| 259 |
<div className="space-y-3">
|
| 260 |
{pools.map((pool) => {
|
| 261 |
-
const
|
| 262 |
-
const
|
| 263 |
-
testingId === pool.id ||
|
| 264 |
-
syncingId === pool.id ||
|
| 265 |
-
deletingId === pool.id ||
|
| 266 |
-
togglingId === pool.id;
|
| 267 |
-
|
| 268 |
return (
|
| 269 |
-
<div
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
? "border-stone-200 bg-white"
|
| 275 |
-
: "border-stone-100 bg-stone-50 opacity-60",
|
| 276 |
-
)}
|
| 277 |
-
>
|
| 278 |
-
<div className="flex items-center justify-between">
|
| 279 |
-
<div className="flex items-center gap-3">
|
| 280 |
-
<div
|
| 281 |
-
className={cn(
|
| 282 |
-
"size-2.5 rounded-full",
|
| 283 |
-
pool.enabled ? "bg-emerald-500" : "bg-stone-300",
|
| 284 |
-
)}
|
| 285 |
-
/>
|
| 286 |
-
<div>
|
| 287 |
-
<div className="text-sm font-medium text-stone-800">
|
| 288 |
-
{pool.name || pool.base_url}
|
| 289 |
-
</div>
|
| 290 |
-
{pool.name && (
|
| 291 |
-
<div className="text-xs text-stone-400">{pool.base_url}</div>
|
| 292 |
-
)}
|
| 293 |
-
</div>
|
| 294 |
</div>
|
| 295 |
<div className="flex items-center gap-1">
|
| 296 |
-
<button
|
| 297 |
-
type="button"
|
| 298 |
-
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
| 299 |
-
onClick={() => void handleToggle(pool)}
|
| 300 |
-
disabled={isBusy}
|
| 301 |
-
title={pool.enabled ? "禁用" : "启用"}
|
| 302 |
-
>
|
| 303 |
-
{togglingId === pool.id ? (
|
| 304 |
-
<LoaderCircle className="size-4 animate-spin" />
|
| 305 |
-
) : pool.enabled ? (
|
| 306 |
-
<Power className="size-4" />
|
| 307 |
-
) : (
|
| 308 |
-
<PowerOff className="size-4" />
|
| 309 |
-
)}
|
| 310 |
-
</button>
|
| 311 |
<button
|
| 312 |
type="button"
|
| 313 |
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
|
@@ -324,50 +366,60 @@ export default function SettingsPage() {
|
|
| 324 |
disabled={isBusy}
|
| 325 |
title="删除"
|
| 326 |
>
|
| 327 |
-
{deletingId === pool.id ?
|
| 328 |
-
<LoaderCircle className="size-4 animate-spin" />
|
| 329 |
-
) : (
|
| 330 |
-
<Trash2 className="size-4" />
|
| 331 |
-
)}
|
| 332 |
</button>
|
| 333 |
</div>
|
| 334 |
</div>
|
| 335 |
|
| 336 |
-
|
| 337 |
-
<
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
>
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
</
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
</div>
|
| 370 |
-
)}
|
| 371 |
</div>
|
| 372 |
);
|
| 373 |
})}
|
|
@@ -377,24 +429,22 @@ export default function SettingsPage() {
|
|
| 377 |
<div className="rounded-xl bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-500">
|
| 378 |
<p className="font-medium text-stone-600">使用说明</p>
|
| 379 |
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
| 380 |
-
<li>
|
| 381 |
-
<li>
|
| 382 |
-
<li>
|
| 383 |
-
<li>
|
| 384 |
-
<li>禁用或删除号池即可停止从该 CPA 获取 token</li>
|
| 385 |
</ul>
|
| 386 |
</div>
|
| 387 |
</CardContent>
|
| 388 |
</Card>
|
| 389 |
</section>
|
| 390 |
|
| 391 |
-
{/* Add / Edit Dialog */}
|
| 392 |
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
| 393 |
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
| 394 |
<DialogHeader className="gap-2">
|
| 395 |
-
<DialogTitle>{editingPool ? "编辑
|
| 396 |
<DialogDescription className="text-sm leading-6">
|
| 397 |
-
{editingPool ? "修改 CPA
|
| 398 |
</DialogDescription>
|
| 399 |
</DialogHeader>
|
| 400 |
<div className="space-y-4">
|
|
@@ -402,7 +452,7 @@ export default function SettingsPage() {
|
|
| 402 |
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
| 403 |
<Input
|
| 404 |
value={formName}
|
| 405 |
-
onChange={(
|
| 406 |
placeholder="例如:主号池、备用池"
|
| 407 |
className="h-11 rounded-xl border-stone-200 bg-white"
|
| 408 |
/>
|
|
@@ -414,7 +464,7 @@ export default function SettingsPage() {
|
|
| 414 |
</label>
|
| 415 |
<Input
|
| 416 |
value={formBaseUrl}
|
| 417 |
-
onChange={(
|
| 418 |
placeholder="http://your-cpa-host:8317"
|
| 419 |
className="h-11 rounded-xl border-stone-200 bg-white"
|
| 420 |
/>
|
|
@@ -428,14 +478,14 @@ export default function SettingsPage() {
|
|
| 428 |
<Input
|
| 429 |
type={showSecret ? "text" : "password"}
|
| 430 |
value={formSecretKey}
|
| 431 |
-
onChange={(
|
| 432 |
-
placeholder="CPA 管理密钥"
|
| 433 |
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
| 434 |
/>
|
| 435 |
<button
|
| 436 |
type="button"
|
| 437 |
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
| 438 |
-
onClick={() => setShowSecret(!
|
| 439 |
>
|
| 440 |
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
| 441 |
</button>
|
|
@@ -462,6 +512,136 @@ export default function SettingsPage() {
|
|
| 462 |
</DialogFooter>
|
| 463 |
</DialogContent>
|
| 464 |
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
</>
|
| 466 |
);
|
| 467 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 4 |
import {
|
|
|
|
| 5 |
Eye,
|
| 6 |
EyeOff,
|
| 7 |
+
Import,
|
| 8 |
Link2,
|
| 9 |
LoaderCircle,
|
| 10 |
Pencil,
|
| 11 |
Plus,
|
|
|
|
|
|
|
|
|
|
| 12 |
Save,
|
| 13 |
+
Search,
|
| 14 |
ServerCog,
|
| 15 |
Trash2,
|
| 16 |
Unplug,
|
|
|
|
| 20 |
import { Badge } from "@/components/ui/badge";
|
| 21 |
import { Button } from "@/components/ui/button";
|
| 22 |
import { Card, CardContent } from "@/components/ui/card";
|
| 23 |
+
import { Checkbox } from "@/components/ui/checkbox";
|
| 24 |
import {
|
| 25 |
Dialog,
|
| 26 |
DialogContent,
|
|
|
|
| 30 |
DialogTitle,
|
| 31 |
} from "@/components/ui/dialog";
|
| 32 |
import { Input } from "@/components/ui/input";
|
| 33 |
+
import {
|
| 34 |
+
Select,
|
| 35 |
+
SelectContent,
|
| 36 |
+
SelectItem,
|
| 37 |
+
SelectTrigger,
|
| 38 |
+
SelectValue,
|
| 39 |
+
} from "@/components/ui/select";
|
| 40 |
import {
|
| 41 |
createCPAPool,
|
| 42 |
deleteCPAPool,
|
| 43 |
+
fetchCPAPoolFiles,
|
| 44 |
fetchCPAPools,
|
| 45 |
+
startCPAImport,
|
| 46 |
updateCPAPool,
|
| 47 |
type CPAPool,
|
| 48 |
+
type CPARemoteFile,
|
| 49 |
} from "@/lib/api";
|
|
|
|
| 50 |
|
| 51 |
+
const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
| 52 |
+
|
| 53 |
+
function normalizeFiles(items: CPARemoteFile[]) {
|
| 54 |
+
const seen = new Set<string>();
|
| 55 |
+
const files: CPARemoteFile[] = [];
|
| 56 |
+
for (const item of items) {
|
| 57 |
+
const name = String(item.name || "").trim();
|
| 58 |
+
if (!name || seen.has(name)) {
|
| 59 |
+
continue;
|
| 60 |
+
}
|
| 61 |
+
seen.add(name);
|
| 62 |
+
files.push({
|
| 63 |
+
name,
|
| 64 |
+
email: String(item.email || "").trim(),
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
return files;
|
| 68 |
+
}
|
| 69 |
|
| 70 |
export default function SettingsPage() {
|
| 71 |
const didLoadRef = useRef(false);
|
| 72 |
+
const pollTimerRef = useRef<number | null>(null);
|
| 73 |
+
|
| 74 |
const [pools, setPools] = useState<CPAPool[]>([]);
|
|
|
|
| 75 |
const [isLoading, setIsLoading] = useState(true);
|
| 76 |
|
|
|
|
| 77 |
const [dialogOpen, setDialogOpen] = useState(false);
|
| 78 |
const [editingPool, setEditingPool] = useState<CPAPool | null>(null);
|
| 79 |
const [formName, setFormName] = useState("");
|
|
|
|
| 82 |
const [showSecret, setShowSecret] = useState(false);
|
| 83 |
const [isSaving, setIsSaving] = useState(false);
|
| 84 |
|
|
|
|
|
|
|
|
|
|
| 85 |
const [deletingId, setDeletingId] = useState<string | null>(null);
|
| 86 |
+
const [loadingFilesId, setLoadingFilesId] = useState<string | null>(null);
|
| 87 |
+
|
| 88 |
+
const [browserOpen, setBrowserOpen] = useState(false);
|
| 89 |
+
const [browserPool, setBrowserPool] = useState<CPAPool | null>(null);
|
| 90 |
+
const [remoteFiles, setRemoteFiles] = useState<CPARemoteFile[]>([]);
|
| 91 |
+
const [selectedNames, setSelectedNames] = useState<string[]>([]);
|
| 92 |
+
const [fileQuery, setFileQuery] = useState("");
|
| 93 |
+
const [filePage, setFilePage] = useState(1);
|
| 94 |
+
const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>("100");
|
| 95 |
+
const [isStartingImport, setIsStartingImport] = useState(false);
|
| 96 |
|
| 97 |
const loadPools = async () => {
|
| 98 |
setIsLoading(true);
|
| 99 |
try {
|
| 100 |
+
const poolData = await fetchCPAPools();
|
| 101 |
+
setPools(poolData.pools);
|
| 102 |
} catch (error) {
|
| 103 |
+
toast.error(error instanceof Error ? error.message : "加载 CPA 连接失败");
|
| 104 |
} finally {
|
| 105 |
setIsLoading(false);
|
| 106 |
}
|
| 107 |
};
|
| 108 |
|
| 109 |
useEffect(() => {
|
| 110 |
+
if (didLoadRef.current) {
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
didLoadRef.current = true;
|
| 114 |
void loadPools();
|
| 115 |
}, []);
|
| 116 |
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
const runningPoolIds = pools
|
| 119 |
+
.filter((pool) => pool.import_job?.status === "pending" || pool.import_job?.status === "running")
|
| 120 |
+
.map((pool) => pool.id);
|
| 121 |
+
const hasRunningJobs = runningPoolIds.length > 0;
|
| 122 |
+
if (!hasRunningJobs) {
|
| 123 |
+
if (pollTimerRef.current !== null) {
|
| 124 |
+
window.clearInterval(pollTimerRef.current);
|
| 125 |
+
pollTimerRef.current = null;
|
| 126 |
+
}
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
pollTimerRef.current = window.setInterval(() => {
|
| 131 |
+
void fetchCPAPools()
|
| 132 |
+
.then((poolData) => {
|
| 133 |
+
setPools(poolData.pools);
|
| 134 |
+
})
|
| 135 |
+
.catch((error) => {
|
| 136 |
+
if (pollTimerRef.current !== null) {
|
| 137 |
+
window.clearInterval(pollTimerRef.current);
|
| 138 |
+
pollTimerRef.current = null;
|
| 139 |
+
}
|
| 140 |
+
toast.error(error instanceof Error ? error.message : "查询导入进度失败");
|
| 141 |
+
});
|
| 142 |
+
}, 1500);
|
| 143 |
+
|
| 144 |
+
return () => {
|
| 145 |
+
if (pollTimerRef.current !== null) {
|
| 146 |
+
window.clearInterval(pollTimerRef.current);
|
| 147 |
+
pollTimerRef.current = null;
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
}, [pools]);
|
| 151 |
+
|
| 152 |
const openAddDialog = () => {
|
| 153 |
setEditingPool(null);
|
| 154 |
setFormName("");
|
|
|
|
| 162 |
setEditingPool(pool);
|
| 163 |
setFormName(pool.name);
|
| 164 |
setFormBaseUrl(pool.base_url);
|
| 165 |
+
setFormSecretKey("");
|
| 166 |
setShowSecret(false);
|
| 167 |
setDialogOpen(true);
|
| 168 |
};
|
|
|
|
| 172 |
toast.error("请输入 CPA 地址");
|
| 173 |
return;
|
| 174 |
}
|
| 175 |
+
if (!editingPool && !formSecretKey.trim()) {
|
| 176 |
toast.error("请输入 Secret Key");
|
| 177 |
return;
|
| 178 |
}
|
| 179 |
+
|
| 180 |
setIsSaving(true);
|
| 181 |
try {
|
| 182 |
if (editingPool) {
|
| 183 |
const data = await updateCPAPool(editingPool.id, {
|
| 184 |
name: formName.trim(),
|
| 185 |
base_url: formBaseUrl.trim(),
|
| 186 |
+
secret_key: formSecretKey.trim() || undefined,
|
| 187 |
});
|
| 188 |
setPools(data.pools);
|
| 189 |
+
toast.success("连接已更新");
|
| 190 |
} else {
|
| 191 |
const data = await createCPAPool({
|
| 192 |
name: formName.trim(),
|
|
|
|
| 194 |
secret_key: formSecretKey.trim(),
|
| 195 |
});
|
| 196 |
setPools(data.pools);
|
| 197 |
+
toast.success("连接已添加");
|
| 198 |
}
|
| 199 |
setDialogOpen(false);
|
| 200 |
} catch (error) {
|
|
|
|
| 204 |
}
|
| 205 |
};
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
const handleDelete = async (pool: CPAPool) => {
|
| 208 |
setDeletingId(pool.id);
|
| 209 |
try {
|
| 210 |
const data = await deleteCPAPool(pool.id);
|
| 211 |
setPools(data.pools);
|
| 212 |
+
toast.success("连接已删除");
|
| 213 |
} catch (error) {
|
| 214 |
toast.error(error instanceof Error ? error.message : "删除失败");
|
| 215 |
} finally {
|
|
|
|
| 217 |
}
|
| 218 |
};
|
| 219 |
|
| 220 |
+
const handleBrowseFiles = async (pool: CPAPool) => {
|
| 221 |
+
setLoadingFilesId(pool.id);
|
| 222 |
try {
|
| 223 |
+
const data = await fetchCPAPoolFiles(pool.id);
|
| 224 |
+
const files = normalizeFiles(data.files);
|
| 225 |
+
setBrowserPool(pool);
|
| 226 |
+
setRemoteFiles(files);
|
| 227 |
+
setSelectedNames([]);
|
| 228 |
+
setFileQuery("");
|
| 229 |
+
setFilePage(1);
|
| 230 |
+
setBrowserOpen(true);
|
| 231 |
+
toast.success(`读取成功,共 ${files.length} 个远程账号`);
|
| 232 |
} catch (error) {
|
| 233 |
+
toast.error(error instanceof Error ? error.message : "读取远程账号失败");
|
| 234 |
} finally {
|
| 235 |
+
setLoadingFilesId(null);
|
| 236 |
}
|
| 237 |
};
|
| 238 |
|
| 239 |
+
const filteredFiles = useMemo(() => {
|
| 240 |
+
const query = fileQuery.trim().toLowerCase();
|
| 241 |
+
if (!query) {
|
| 242 |
+
return remoteFiles;
|
| 243 |
+
}
|
| 244 |
+
return remoteFiles.filter((item) => {
|
| 245 |
+
return item.email.toLowerCase().includes(query) || item.name.toLowerCase().includes(query);
|
| 246 |
+
});
|
| 247 |
+
}, [fileQuery, remoteFiles]);
|
| 248 |
+
|
| 249 |
+
const currentPageSize = Number(pageSize);
|
| 250 |
+
const filePageCount = Math.max(1, Math.ceil(filteredFiles.length / currentPageSize));
|
| 251 |
+
const safeFilePage = Math.min(filePage, filePageCount);
|
| 252 |
+
const pagedFiles = filteredFiles.slice((safeFilePage - 1) * currentPageSize, safeFilePage * currentPageSize);
|
| 253 |
+
const allFilteredSelected = filteredFiles.length > 0 && filteredFiles.every((item) => selectedNames.includes(item.name));
|
| 254 |
+
|
| 255 |
+
const toggleFile = (name: string, checked: boolean) => {
|
| 256 |
+
setSelectedNames((prev) => {
|
| 257 |
+
if (checked) {
|
| 258 |
+
return Array.from(new Set([...prev, name]));
|
| 259 |
}
|
| 260 |
+
return prev.filter((item) => item !== name);
|
| 261 |
+
});
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
const handleToggleSelectAllFiltered = (checked: boolean) => {
|
| 265 |
+
if (checked) {
|
| 266 |
+
setSelectedNames(Array.from(new Set([...selectedNames, ...filteredFiles.map((item) => item.name)])));
|
| 267 |
+
return;
|
| 268 |
+
}
|
| 269 |
+
const filteredSet = new Set(filteredFiles.map((item) => item.name));
|
| 270 |
+
setSelectedNames((prev) => prev.filter((name) => !filteredSet.has(name)));
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
const handleStartImport = async () => {
|
| 274 |
+
if (!browserPool) {
|
| 275 |
+
return;
|
| 276 |
+
}
|
| 277 |
+
if (selectedNames.length === 0) {
|
| 278 |
+
toast.error("请先选择要导入的账号");
|
| 279 |
+
return;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
setIsStartingImport(true);
|
| 283 |
+
try {
|
| 284 |
+
const result = await startCPAImport(browserPool.id, selectedNames);
|
| 285 |
+
setPools((prev) =>
|
| 286 |
+
prev.map((pool) => (pool.id === browserPool.id ? { ...pool, import_job: result.import_job } : pool)),
|
| 287 |
+
);
|
| 288 |
+
setBrowserOpen(false);
|
| 289 |
+
toast.success("导入任务已启动");
|
| 290 |
} catch (error) {
|
| 291 |
+
toast.error(error instanceof Error ? error.message : "启动导入失败");
|
| 292 |
} finally {
|
| 293 |
+
setIsStartingImport(false);
|
| 294 |
}
|
| 295 |
};
|
| 296 |
|
|
|
|
|
|
|
| 297 |
return (
|
| 298 |
<>
|
| 299 |
<section className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
|
|
| 312 |
<ServerCog className="size-5 text-stone-600" />
|
| 313 |
</div>
|
| 314 |
<div>
|
| 315 |
+
<h2 className="text-lg font-semibold tracking-tight">CPA 连接管理</h2>
|
| 316 |
+
<p className="text-sm text-stone-500">先配置连接,再按需查询远程账号并选择导入到本地号池。</p>
|
|
|
|
|
|
|
| 317 |
</div>
|
| 318 |
</div>
|
| 319 |
<div className="flex items-center gap-2">
|
| 320 |
+
{pools.length > 0 ? <Badge className="rounded-md px-2.5 py-1">{pools.length} 个连接</Badge> : null}
|
| 321 |
+
<Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={openAddDialog}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
<Plus className="size-4" />
|
| 323 |
+
添加连接
|
| 324 |
</Button>
|
| 325 |
</div>
|
| 326 |
</div>
|
|
|
|
| 333 |
<div className="flex flex-col items-center justify-center gap-3 rounded-xl bg-stone-50 px-6 py-10 text-center">
|
| 334 |
<ServerCog className="size-8 text-stone-300" />
|
| 335 |
<div className="space-y-1">
|
| 336 |
+
<p className="text-sm font-medium text-stone-600">暂无 CPA 连接</p>
|
| 337 |
+
<p className="text-sm text-stone-400">点击「添加连接」保存你的 CLIProxyAPI 信息。</p>
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
) : (
|
| 341 |
<div className="space-y-3">
|
| 342 |
{pools.map((pool) => {
|
| 343 |
+
const isBusy = deletingId === pool.id || loadingFilesId === pool.id;
|
| 344 |
+
const importJob = pool.import_job ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
return (
|
| 346 |
+
<div key={pool.id} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
| 347 |
+
<div className="flex items-center justify-between gap-3">
|
| 348 |
+
<div className="min-w-0">
|
| 349 |
+
<div className="text-sm font-medium text-stone-800">{pool.name || pool.base_url}</div>
|
| 350 |
+
<div className="truncate text-xs text-stone-400">{pool.base_url}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
</div>
|
| 352 |
<div className="flex items-center gap-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
<button
|
| 354 |
type="button"
|
| 355 |
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
|
|
|
| 366 |
disabled={isBusy}
|
| 367 |
title="删除"
|
| 368 |
>
|
| 369 |
+
{deletingId === pool.id ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
</button>
|
| 371 |
</div>
|
| 372 |
</div>
|
| 373 |
|
| 374 |
+
<div className="flex items-center gap-2">
|
| 375 |
+
<Button
|
| 376 |
+
variant="outline"
|
| 377 |
+
className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
|
| 378 |
+
onClick={() => void handleBrowseFiles(pool)}
|
| 379 |
+
disabled={isBusy}
|
| 380 |
+
>
|
| 381 |
+
{loadingFilesId === pool.id ? <LoaderCircle className="size-3.5 animate-spin" /> : <Import className="size-3.5" />}
|
| 382 |
+
同步
|
| 383 |
+
</Button>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
+
{importJob ? (
|
| 387 |
+
<div className="space-y-2 rounded-xl bg-stone-50 px-3 py-3">
|
| 388 |
+
<div className="text-xs font-medium tracking-[0.16em] text-stone-400 uppercase">导入任务</div>
|
| 389 |
+
{(() => {
|
| 390 |
+
const progress = importJob.total > 0 ? Math.round((importJob.completed / importJob.total) * 100) : 0;
|
| 391 |
+
return (
|
| 392 |
+
<div className="rounded-lg border border-stone-200 bg-white px-3 py-3">
|
| 393 |
+
<div className="flex items-center justify-between gap-3">
|
| 394 |
+
<div className="min-w-0">
|
| 395 |
+
<div className="text-sm font-medium text-stone-700">
|
| 396 |
+
状态 {importJob.status},已处理 {importJob.completed}/{importJob.total}
|
| 397 |
+
</div>
|
| 398 |
+
<div className="truncate text-xs text-stone-400">
|
| 399 |
+
任务 {importJob.job_id.slice(0, 8)} · {importJob.created_at}
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
<Badge
|
| 403 |
+
variant={importJob.status === "completed" ? "success" : importJob.status === "failed" ? "danger" : "info"}
|
| 404 |
+
className="rounded-md"
|
| 405 |
+
>
|
| 406 |
+
{progress}%
|
| 407 |
+
</Badge>
|
| 408 |
+
</div>
|
| 409 |
+
<div className="mt-3 h-2 overflow-hidden rounded-full bg-stone-200">
|
| 410 |
+
<div className="h-full rounded-full bg-stone-900 transition-all" style={{ width: `${progress}%` }} />
|
| 411 |
+
</div>
|
| 412 |
+
<div className="mt-2 flex flex-wrap gap-2 text-xs text-stone-500">
|
| 413 |
+
<span>新增 {importJob.added}</span>
|
| 414 |
+
<span>跳过 {importJob.skipped}</span>
|
| 415 |
+
<span>刷新 {importJob.refreshed}</span>
|
| 416 |
+
<span>失败 {importJob.failed}</span>
|
| 417 |
+
</div>
|
| 418 |
+
</div>
|
| 419 |
+
);
|
| 420 |
+
})()}
|
| 421 |
</div>
|
| 422 |
+
) : null}
|
| 423 |
</div>
|
| 424 |
);
|
| 425 |
})}
|
|
|
|
| 429 |
<div className="rounded-xl bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-500">
|
| 430 |
<p className="font-medium text-stone-600">使用说明</p>
|
| 431 |
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
| 432 |
+
<li>页面进入后先读取系统里已配置的 CPA 连接。</li>
|
| 433 |
+
<li>点击某个连接的「同步」后,会先读取远程账号列表并展示给前端选择。</li>
|
| 434 |
+
<li>确认选择后,后端后台下载对应 access_token 并导入本地号池。</li>
|
| 435 |
+
<li>前端只轮询导入进度,不直接参与 download。</li>
|
|
|
|
| 436 |
</ul>
|
| 437 |
</div>
|
| 438 |
</CardContent>
|
| 439 |
</Card>
|
| 440 |
</section>
|
| 441 |
|
|
|
|
| 442 |
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
| 443 |
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
| 444 |
<DialogHeader className="gap-2">
|
| 445 |
+
<DialogTitle>{editingPool ? "编辑连接" : "添加连接"}</DialogTitle>
|
| 446 |
<DialogDescription className="text-sm leading-6">
|
| 447 |
+
{editingPool ? "修改 CPA 连接信息" : "添加一个新的 CLIProxyAPI 连接"}
|
| 448 |
</DialogDescription>
|
| 449 |
</DialogHeader>
|
| 450 |
<div className="space-y-4">
|
|
|
|
| 452 |
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
| 453 |
<Input
|
| 454 |
value={formName}
|
| 455 |
+
onChange={(event) => setFormName(event.target.value)}
|
| 456 |
placeholder="例如:主号池、备用池"
|
| 457 |
className="h-11 rounded-xl border-stone-200 bg-white"
|
| 458 |
/>
|
|
|
|
| 464 |
</label>
|
| 465 |
<Input
|
| 466 |
value={formBaseUrl}
|
| 467 |
+
onChange={(event) => setFormBaseUrl(event.target.value)}
|
| 468 |
placeholder="http://your-cpa-host:8317"
|
| 469 |
className="h-11 rounded-xl border-stone-200 bg-white"
|
| 470 |
/>
|
|
|
|
| 478 |
<Input
|
| 479 |
type={showSecret ? "text" : "password"}
|
| 480 |
value={formSecretKey}
|
| 481 |
+
onChange={(event) => setFormSecretKey(event.target.value)}
|
| 482 |
+
placeholder={editingPool ? "留空则不修改密钥" : "CPA 管理密钥"}
|
| 483 |
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
| 484 |
/>
|
| 485 |
<button
|
| 486 |
type="button"
|
| 487 |
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
| 488 |
+
onClick={() => setShowSecret((prev) => !prev)}
|
| 489 |
>
|
| 490 |
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
| 491 |
</button>
|
|
|
|
| 512 |
</DialogFooter>
|
| 513 |
</DialogContent>
|
| 514 |
</Dialog>
|
| 515 |
+
|
| 516 |
+
<Dialog open={browserOpen} onOpenChange={setBrowserOpen}>
|
| 517 |
+
<DialogContent showCloseButton={false} className="max-h-[90vh] max-w-5xl rounded-2xl p-6">
|
| 518 |
+
<DialogHeader className="gap-2">
|
| 519 |
+
<DialogTitle>选择要导入的账号</DialogTitle>
|
| 520 |
+
<DialogDescription className="text-sm leading-6">
|
| 521 |
+
{browserPool ? `来自 ${browserPool.name || browserPool.base_url}` : "读取到的远程账号列表"}
|
| 522 |
+
</DialogDescription>
|
| 523 |
+
</DialogHeader>
|
| 524 |
+
|
| 525 |
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
| 526 |
+
<div className="relative min-w-[260px]">
|
| 527 |
+
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-stone-400" />
|
| 528 |
+
<Input
|
| 529 |
+
value={fileQuery}
|
| 530 |
+
onChange={(event) => {
|
| 531 |
+
setFileQuery(event.target.value);
|
| 532 |
+
setFilePage(1);
|
| 533 |
+
}}
|
| 534 |
+
placeholder="搜索 email 或文件名"
|
| 535 |
+
className="h-10 rounded-xl border-stone-200 bg-white pl-10"
|
| 536 |
+
/>
|
| 537 |
+
</div>
|
| 538 |
+
<div className="flex items-center gap-2">
|
| 539 |
+
<Select
|
| 540 |
+
value={pageSize}
|
| 541 |
+
onValueChange={(value) => {
|
| 542 |
+
setPageSize(value as (typeof PAGE_SIZE_OPTIONS)[number]);
|
| 543 |
+
setFilePage(1);
|
| 544 |
+
}}
|
| 545 |
+
>
|
| 546 |
+
<SelectTrigger className="h-10 w-[120px] rounded-xl border-stone-200 bg-white">
|
| 547 |
+
<SelectValue />
|
| 548 |
+
</SelectTrigger>
|
| 549 |
+
<SelectContent>
|
| 550 |
+
{PAGE_SIZE_OPTIONS.map((item) => (
|
| 551 |
+
<SelectItem key={item} value={item}>
|
| 552 |
+
{item} / 页
|
| 553 |
+
</SelectItem>
|
| 554 |
+
))}
|
| 555 |
+
</SelectContent>
|
| 556 |
+
</Select>
|
| 557 |
+
<Button
|
| 558 |
+
variant="outline"
|
| 559 |
+
className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
| 560 |
+
onClick={() => handleToggleSelectAllFiltered(!allFilteredSelected)}
|
| 561 |
+
>
|
| 562 |
+
{allFilteredSelected ? "取消全选" : "全选筛选结果"}
|
| 563 |
+
</Button>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
|
| 567 |
+
<div className="rounded-xl border border-stone-200">
|
| 568 |
+
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3 text-sm text-stone-500">
|
| 569 |
+
<div className="flex items-center gap-3">
|
| 570 |
+
<Checkbox checked={allFilteredSelected} onCheckedChange={(checked) => handleToggleSelectAllFiltered(Boolean(checked))} />
|
| 571 |
+
<span>筛选结果 {filteredFiles.length} 个</span>
|
| 572 |
+
</div>
|
| 573 |
+
<span>已选 {selectedNames.length} 个</span>
|
| 574 |
+
</div>
|
| 575 |
+
<div className="max-h-[420px] overflow-auto">
|
| 576 |
+
{pagedFiles.length === 0 ? (
|
| 577 |
+
<div className="flex items-center justify-center py-12 text-sm text-stone-400">没有匹配的远程账号</div>
|
| 578 |
+
) : (
|
| 579 |
+
<div className="divide-y divide-stone-100">
|
| 580 |
+
{pagedFiles.map((item) => (
|
| 581 |
+
<label key={item.name} className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-stone-50">
|
| 582 |
+
<Checkbox
|
| 583 |
+
checked={selectedNames.includes(item.name)}
|
| 584 |
+
onCheckedChange={(checked) => toggleFile(item.name, Boolean(checked))}
|
| 585 |
+
/>
|
| 586 |
+
<div className="min-w-0">
|
| 587 |
+
<div className="truncate text-sm font-medium text-stone-700">{item.email || item.name}</div>
|
| 588 |
+
<div className="truncate text-xs text-stone-400">{item.name}</div>
|
| 589 |
+
</div>
|
| 590 |
+
</label>
|
| 591 |
+
))}
|
| 592 |
+
</div>
|
| 593 |
+
)}
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
|
| 597 |
+
<div className="flex items-center justify-between text-sm text-stone-500">
|
| 598 |
+
<span>
|
| 599 |
+
第 {filteredFiles.length === 0 ? 0 : (safeFilePage - 1) * currentPageSize + 1} -{" "}
|
| 600 |
+
{Math.min(safeFilePage * currentPageSize, filteredFiles.length)} 条,共 {filteredFiles.length} 条
|
| 601 |
+
</span>
|
| 602 |
+
<div className="flex items-center gap-2">
|
| 603 |
+
<Button
|
| 604 |
+
variant="outline"
|
| 605 |
+
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
| 606 |
+
onClick={() => setFilePage((prev) => Math.max(1, prev - 1))}
|
| 607 |
+
disabled={safeFilePage <= 1}
|
| 608 |
+
>
|
| 609 |
+
上一页
|
| 610 |
+
</Button>
|
| 611 |
+
<span>
|
| 612 |
+
{safeFilePage}/{filePageCount}
|
| 613 |
+
</span>
|
| 614 |
+
<Button
|
| 615 |
+
variant="outline"
|
| 616 |
+
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
| 617 |
+
onClick={() => setFilePage((prev) => Math.min(filePageCount, prev + 1))}
|
| 618 |
+
disabled={safeFilePage >= filePageCount}
|
| 619 |
+
>
|
| 620 |
+
下一页
|
| 621 |
+
</Button>
|
| 622 |
+
</div>
|
| 623 |
+
</div>
|
| 624 |
+
|
| 625 |
+
<DialogFooter className="pt-2">
|
| 626 |
+
<Button
|
| 627 |
+
variant="secondary"
|
| 628 |
+
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
| 629 |
+
onClick={() => setBrowserOpen(false)}
|
| 630 |
+
disabled={isStartingImport}
|
| 631 |
+
>
|
| 632 |
+
取消
|
| 633 |
+
</Button>
|
| 634 |
+
<Button
|
| 635 |
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 636 |
+
onClick={() => void handleStartImport()}
|
| 637 |
+
disabled={isStartingImport || selectedNames.length === 0}
|
| 638 |
+
>
|
| 639 |
+
{isStartingImport ? <LoaderCircle className="size-4 animate-spin" /> : <Import className="size-4" />}
|
| 640 |
+
导入选中账号
|
| 641 |
+
</Button>
|
| 642 |
+
</DialogFooter>
|
| 643 |
+
</DialogContent>
|
| 644 |
+
</Dialog>
|
| 645 |
</>
|
| 646 |
);
|
| 647 |
}
|
web/src/lib/api.ts
CHANGED
|
@@ -123,21 +123,33 @@ export type CPAPool = {
|
|
| 123 |
id: string;
|
| 124 |
name: string;
|
| 125 |
base_url: string;
|
| 126 |
-
|
| 127 |
-
enabled: boolean;
|
| 128 |
};
|
| 129 |
|
| 130 |
-
export type
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
};
|
| 135 |
|
| 136 |
export async function fetchCPAPools() {
|
| 137 |
return httpRequest<{ pools: CPAPool[] }>("/api/cpa/pools");
|
| 138 |
}
|
| 139 |
|
| 140 |
-
export async function createCPAPool(pool: { name: string; base_url: string; secret_key: string
|
| 141 |
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>("/api/cpa/pools", {
|
| 142 |
method: "POST",
|
| 143 |
body: pool,
|
|
@@ -146,7 +158,7 @@ export async function createCPAPool(pool: { name: string; base_url: string; secr
|
|
| 146 |
|
| 147 |
export async function updateCPAPool(
|
| 148 |
poolId: string,
|
| 149 |
-
updates: { name?: string; base_url?: string; secret_key?: string
|
| 150 |
) {
|
| 151 |
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>(`/api/cpa/pools/${poolId}`, {
|
| 152 |
method: "POST",
|
|
@@ -160,20 +172,17 @@ export async function deleteCPAPool(poolId: string) {
|
|
| 160 |
});
|
| 161 |
}
|
| 162 |
|
| 163 |
-
export async function
|
| 164 |
-
return httpRequest<{ pool_id: string;
|
| 165 |
}
|
| 166 |
|
| 167 |
-
export async function
|
| 168 |
-
return httpRequest<{
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
errors: Array<{ access_token: string; error: string }>;
|
| 173 |
-
items: Account[];
|
| 174 |
-
}>(`/api/cpa/pools/${poolId}/sync`, { method: "POST" });
|
| 175 |
}
|
| 176 |
|
| 177 |
-
export async function
|
| 178 |
-
return httpRequest<
|
| 179 |
}
|
|
|
|
| 123 |
id: string;
|
| 124 |
name: string;
|
| 125 |
base_url: string;
|
| 126 |
+
import_job?: CPAImportJob | null;
|
|
|
|
| 127 |
};
|
| 128 |
|
| 129 |
+
export type CPARemoteFile = {
|
| 130 |
+
name: string;
|
| 131 |
+
email: string;
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
export type CPAImportJob = {
|
| 135 |
+
job_id: string;
|
| 136 |
+
status: "pending" | "running" | "completed" | "failed";
|
| 137 |
+
created_at: string;
|
| 138 |
+
updated_at: string;
|
| 139 |
+
total: number;
|
| 140 |
+
completed: number;
|
| 141 |
+
added: number;
|
| 142 |
+
skipped: number;
|
| 143 |
+
refreshed: number;
|
| 144 |
+
failed: number;
|
| 145 |
+
errors: Array<{ name: string; error: string }>;
|
| 146 |
};
|
| 147 |
|
| 148 |
export async function fetchCPAPools() {
|
| 149 |
return httpRequest<{ pools: CPAPool[] }>("/api/cpa/pools");
|
| 150 |
}
|
| 151 |
|
| 152 |
+
export async function createCPAPool(pool: { name: string; base_url: string; secret_key: string }) {
|
| 153 |
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>("/api/cpa/pools", {
|
| 154 |
method: "POST",
|
| 155 |
body: pool,
|
|
|
|
| 158 |
|
| 159 |
export async function updateCPAPool(
|
| 160 |
poolId: string,
|
| 161 |
+
updates: { name?: string; base_url?: string; secret_key?: string },
|
| 162 |
) {
|
| 163 |
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>(`/api/cpa/pools/${poolId}`, {
|
| 164 |
method: "POST",
|
|
|
|
| 172 |
});
|
| 173 |
}
|
| 174 |
|
| 175 |
+
export async function fetchCPAPoolFiles(poolId: string) {
|
| 176 |
+
return httpRequest<{ pool_id: string; files: CPARemoteFile[] }>(`/api/cpa/pools/${poolId}/files`);
|
| 177 |
}
|
| 178 |
|
| 179 |
+
export async function startCPAImport(poolId: string, names: string[]) {
|
| 180 |
+
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/cpa/pools/${poolId}/import`, {
|
| 181 |
+
method: "POST",
|
| 182 |
+
body: { names },
|
| 183 |
+
});
|
|
|
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
+
export async function fetchCPAPoolImportJob(poolId: string) {
|
| 187 |
+
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/cpa/pools/${poolId}/import`);
|
| 188 |
}
|