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 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 cpa_service, cpa_config, fetch_tokens_for_pool, fetch_pool_status
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
- enabled: bool | None = None
 
 
 
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
- cpa_service.invalidate_cache()
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
- cpa_service.invalidate_cache()
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
- cpa_service.invalidate_cache()
329
- return {"pools": cpa_config.list_pools()}
330
 
331
- @router.get("/api/cpa/pools/{pool_id}/status")
332
- async def cpa_pool_status(
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
- result = await run_in_threadpool(fetch_pool_status, pool)
341
- return result
342
 
343
- @router.post("/api/cpa/pools/{pool_id}/sync")
344
- async def cpa_pool_sync(
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
- tokens = await run_in_threadpool(fetch_tokens_for_pool, pool)
354
- if not tokens:
355
- raise HTTPException(status_code=502, detail={"error": "No tokens returned from CPA"})
356
- result = account_service.add_accounts(tokens)
357
- refresh_result = account_service.refresh_accounts(tokens)
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/status")
366
- async def cpa_global_status(authorization: str | None = Header(default=None)):
367
  require_auth_key(authorization)
368
- if not cpa_config.has_usable:
369
- return {"enabled": False, "pools": 0, "tokens": 0}
370
- tokens = await run_in_threadpool(cpa_service.fetch_all_tokens)
371
- return {"enabled": True, "pools": len(cpa_config.usable_pools()), "tokens": len(tokens)}
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 — fetch access_tokens from multiple remote CPA instances."""
2
 
3
  from __future__ import annotations
4
 
5
  import json
6
- import time
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.config import config, DATA_DIR
 
16
 
17
 
18
  CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
19
 
20
 
21
- # ── Pool entry type ──────────────────────────────────────────────────
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 _new_pool_id()).strip(),
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
- "enabled": bool(raw.get("enabled", True)),
34
  }
35
 
36
 
37
- def _is_pool_usable(pool: dict) -> bool:
38
- return bool(pool.get("enabled") and pool.get("base_url") and pool.get("secret_key"))
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(p) for p in self._pools]
82
 
83
  def get_pool(self, pool_id: str) -> dict | None:
84
  with self._lock:
85
- for p in self._pools:
86
- if p["id"] == pool_id:
87
- return dict(p)
88
  return None
89
 
90
- def add_pool(self, name: str, base_url: str, secret_key: str, enabled: bool = True) -> dict:
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 i, p in enumerate(self._pools):
106
- if p["id"] == pool_id:
107
- merged = {**p, **{k: v for k, v in updates.items() if v is not None}, "id": pool_id}
108
- self._pools[i] = _normalize_pool(merged)
109
- self._save()
110
- return dict(self._pools[i])
 
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 = [p for p in self._pools if p["id"] != pool_id]
117
  if len(self._pools) < before:
118
  self._save()
119
  return True
120
  return False
121
 
122
- def usable_pools(self) -> list[dict]:
123
  with self._lock:
124
- return [dict(p) for p in self._pools if _is_pool_usable(p)]
 
 
 
 
 
 
 
 
125
 
126
- @property
127
- def has_usable(self) -> bool:
128
  with self._lock:
129
- return any(_is_pool_usable(p) for p in self._pools)
130
-
131
-
132
- # ── CPA fetcher (per-pool) ──────────────────────────────────────────
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 fetch_tokens_for_pool(pool: dict) -> list[str]:
184
- """Fetch all access_tokens from a single CPA pool."""
185
- base_url = pool.get("base_url") or ""
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
- print(f"[cpa-service] [{pool_name}] fetching from {url}")
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
- print(f"[cpa-service] [{pool_name}] HTTP {response.status_code}")
199
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  payload = response.json()
201
  except Exception as exc:
202
- print(f"[cpa-service] [{pool_name}] error: {exc}")
203
- return []
 
204
 
205
- file_entries = _resolve_file_list(payload)
206
- active_entries = [
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
- all_tokens: list[str] = []
292
- seen: set[str] = set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- max_workers = min(5, len(pools))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
296
- future_map = {executor.submit(fetch_tokens_for_pool, pool): pool for pool in pools}
297
  for future in as_completed(future_map):
 
298
  try:
299
- tokens = future.result()
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
- pool = future_map[future]
306
- print(f"[cpa-service] pool {pool.get('name', pool.get('id'))} error: {exc}")
307
-
308
- print(f"[cpa-service] total {len(all_tokens)} token(s) from {len(pools)} pool(s)")
309
- return all_tokens
310
-
311
- def get_token(self, excluded_tokens: set[str] | None = None) -> str | None:
312
- with self._lock:
313
- now = time.time()
314
- if not self._tokens or (now - self._last_refresh) > self._cache_ttl:
315
- self._tokens = self.fetch_all_tokens()
316
- self._last_refresh = now
317
- if self._tokens:
318
- self._index = 0
319
-
320
- excluded = {t for t in (excluded_tokens or set()) if t}
321
- available = [t for t in self._tokens if t not in excluded]
322
- if not available:
323
- return None
324
-
325
- token = available[self._index % len(available)]
326
- self._index += 1
327
- return token
328
-
329
- def invalidate_cache(self) -> None:
330
- with self._lock:
331
- self._last_refresh = 0
332
-
 
 
 
 
 
333
 
334
- # ── Singletons ──────────────────────────────────────────────────────
335
 
336
  cpa_config = CPAConfig(CPA_CONFIG_FILE)
337
- cpa_service = CPAService(cpa_config)
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
- fetchCPAPoolStatus,
38
  fetchCPAPools,
39
- syncCPAPool,
40
  updateCPAPool,
41
  type CPAPool,
 
42
  } from "@/lib/api";
43
- import { cn } from "@/lib/utils";
44
 
45
- type PoolStatus = { pool_id: string; tokens: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [togglingId, setTogglingId] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
67
 
68
  const loadPools = async () => {
69
  setIsLoading(true);
70
  try {
71
- const data = await fetchCPAPools();
72
- setPools(data.pools);
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) return;
 
 
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(pool.secret_key);
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 handleTest = async (pool: CPAPool) => {
167
- setTestingId(pool.id);
168
  try {
169
- const result = await fetchCPAPoolStatus(pool.id);
170
- setPoolStatuses((prev) => ({ ...prev, [pool.id]: result }));
171
- if (result.tokens > 0) {
172
- toast.success(`连接成功,获取到 ${result.tokens} 个 token`);
173
- } else {
174
- toast.warning("连接成功,但未获取到 token");
175
- }
 
 
176
  } catch (error) {
177
- toast.error(error instanceof Error ? error.message : "连接测试失败");
178
  } finally {
179
- setTestingId(null);
180
  }
181
  };
182
 
183
- const handleSync = async (pool: CPAPool) => {
184
- setSyncingId(pool.id);
185
- try {
186
- const result = await syncCPAPool(pool.id);
187
- if ((result.errors?.length ?? 0) > 0) {
188
- toast.warning(
189
- `同步完成:新增 ${result.added},刷新 ${result.refreshed},失败 ${result.errors.length}`,
190
- );
191
- } else {
192
- toast.success(`同步完成:新增 ${result.added},跳过 ${result.skipped},刷新 ${result.refreshed}`);
 
 
 
 
 
 
 
 
 
 
193
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  } catch (error) {
195
- toast.error(error instanceof Error ? error.message : "同步失败");
196
  } finally {
197
- setSyncingId(null);
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 号池管理</h2>
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
- <Badge
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 号池</p>
255
- <p className="text-sm text-stone-400">点击「添加号池」连接你的 CLIProxyAPI 实例</p>
256
  </div>
257
  </div>
258
  ) : (
259
  <div className="space-y-3">
260
  {pools.map((pool) => {
261
- const status = poolStatuses[pool.id];
262
- const isBusy =
263
- testingId === pool.id ||
264
- syncingId === pool.id ||
265
- deletingId === pool.id ||
266
- togglingId === pool.id;
267
-
268
  return (
269
- <div
270
- key={pool.id}
271
- className={cn(
272
- "flex flex-col gap-3 rounded-xl border px-4 py-3 transition",
273
- pool.enabled
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
- {pool.enabled && (
337
- <div className="flex items-center gap-2">
338
- <Button
339
- variant="outline"
340
- className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
341
- onClick={() => void handleTest(pool)}
342
- disabled={isBusy}
343
- >
344
- {testingId === pool.id ? (
345
- <LoaderCircle className="size-3.5 animate-spin" />
346
- ) : (
347
- <RefreshCw className="size-3.5" />
348
- )}
349
- 测试连接
350
- </Button>
351
- <Button
352
- variant="outline"
353
- className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
354
- onClick={() => void handleSync(pool)}
355
- disabled={isBusy}
356
- >
357
- {syncingId === pool.id ? (
358
- <LoaderCircle className="size-3.5 animate-spin" />
359
- ) : (
360
- <RefreshCw className="size-3.5" />
361
- )}
362
- 同步到号池
363
- </Button>
364
- {status && (
365
- <span className="text-xs text-stone-400">
366
- 可用 Token: {status.tokens}
367
- </span>
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>添加多个 CPA 号池后,生图请求会自动从所有启用的号池获取 token</li>
381
- <li>也可以点击「同步到号池」将某个 CPA 的 token 导入本地号池管理</li>
382
- <li>CPA 需要开启 remote-management 并设置 allow-remote: true</li>
383
- <li>同步 token 不会影响 CPA 原有的登录态</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 ? "编辑号池" : "添加号池"}</DialogTitle>
396
  <DialogDescription className="text-sm leading-6">
397
- {editingPool ? "修改 CPA 号池的连接信息" : "添加一个新的 CLIProxyAPI 号池"}
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={(e) => setFormName(e.target.value)}
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={(e) => setFormBaseUrl(e.target.value)}
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={(e) => setFormSecretKey(e.target.value)}
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(!showSecret)}
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
- secret_key: string;
127
- enabled: boolean;
128
  };
129
 
130
- export type CPAStatus = {
131
- enabled: boolean;
132
- pools: number;
133
- tokens: number;
 
 
 
 
 
 
 
 
 
 
 
 
 
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; enabled?: boolean }) {
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; enabled?: boolean },
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 fetchCPAPoolStatus(poolId: string) {
164
- return httpRequest<{ pool_id: string; tokens: number }>(`/api/cpa/pools/${poolId}/status`);
165
  }
166
 
167
- export async function syncCPAPool(poolId: string) {
168
- return httpRequest<{
169
- added: number;
170
- skipped: number;
171
- refreshed: number;
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 fetchCPAGlobalStatus() {
178
- return httpRequest<CPAStatus>("/api/cpa/status");
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
  }