zxxxxxxxxx commited on
Commit
3f37e67
·
2 Parent(s): 1ca1fd4d7576ec

Merge branch 'basketikun:main' into main

Browse files
services/api.py CHANGED
@@ -13,6 +13,8 @@ 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.image_service import ImageGenerationError
17
  from services.version import get_app_version
18
 
@@ -68,6 +70,22 @@ class ResponseCreateRequest(BaseModel):
68
  stream: bool | None = None
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def build_model_item(model_id: str) -> dict[str, object]:
72
  return {
73
  "id": model_id,
@@ -77,6 +95,20 @@ def build_model_item(model_id: str) -> dict[str, object]:
77
  }
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def extract_bearer_token(authorization: str | None) -> str:
81
  scheme, _, value = str(authorization or "").partition(" ")
82
  if scheme.lower() != "bearer" or not value.strip():
@@ -117,7 +149,11 @@ def resolve_web_asset(requested_path: str) -> Path | None:
117
  candidates = [WEB_DIST_DIR / "index.html"]
118
  else:
119
  relative_path = Path(clean_path)
120
- candidates = [WEB_DIST_DIR / relative_path, WEB_DIST_DIR / relative_path / "index.html", WEB_DIST_DIR / f"{clean_path}.html"]
 
 
 
 
121
 
122
  for candidate in candidates:
123
  try:
@@ -156,7 +192,13 @@ def create_app() -> FastAPI:
156
 
157
  @router.get("/v1/models")
158
  async def list_models():
159
- return {"object": "list", "data": [build_model_item("gpt-image-1"), build_model_item("gpt-image-2")]}
 
 
 
 
 
 
160
 
161
  @router.post("/auth/login")
162
  async def login(authorization: str | None = Header(default=None)):
@@ -180,7 +222,12 @@ def create_app() -> FastAPI:
180
  raise HTTPException(status_code=400, detail={"error": "tokens is required"})
181
  result = account_service.add_accounts(tokens)
182
  refresh_result = account_service.refresh_accounts(tokens)
183
- return {**result, "refreshed": refresh_result.get("refreshed", 0), "errors": refresh_result.get("errors", []), "items": refresh_result.get("items", result.get("items", []))}
 
 
 
 
 
184
 
185
  @router.delete("/api/accounts")
186
  async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
@@ -207,7 +254,15 @@ def create_app() -> FastAPI:
207
  if not access_token:
208
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
209
 
210
- updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
 
 
 
 
 
 
 
 
211
  if not updates:
212
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
213
 
@@ -234,6 +289,88 @@ def create_app() -> FastAPI:
234
  require_auth_key(authorization)
235
  return await run_in_threadpool(chatgpt_service.create_response, body.model_dump(mode="python"))
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  app.include_router(router)
238
 
239
  @app.get("/{full_path:path}", include_in_schema=False)
@@ -242,6 +379,10 @@ def create_app() -> FastAPI:
242
  if asset is not None:
243
  return FileResponse(asset)
244
 
 
 
 
 
245
  fallback = resolve_web_asset("")
246
  if fallback is None:
247
  raise HTTPException(status_code=404, detail="Not Found")
 
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
20
 
 
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]:
90
  return {
91
  "id": model_id,
 
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():
 
149
  candidates = [WEB_DIST_DIR / "index.html"]
150
  else:
151
  relative_path = Path(clean_path)
152
+ candidates = [
153
+ WEB_DIST_DIR / relative_path,
154
+ WEB_DIST_DIR / relative_path / "index.html",
155
+ WEB_DIST_DIR / f"{clean_path}.html",
156
+ ]
157
 
158
  for candidate in candidates:
159
  try:
 
192
 
193
  @router.get("/v1/models")
194
  async def list_models():
195
+ return {
196
+ "object": "list",
197
+ "data": [
198
+ build_model_item("gpt-image-1"),
199
+ build_model_item("gpt-image-2"),
200
+ ],
201
+ }
202
 
203
  @router.post("/auth/login")
204
  async def login(authorization: str | None = Header(default=None)):
 
222
  raise HTTPException(status_code=400, detail={"error": "tokens is required"})
223
  result = account_service.add_accounts(tokens)
224
  refresh_result = account_service.refresh_accounts(tokens)
225
+ return {
226
+ **result,
227
+ "refreshed": refresh_result.get("refreshed", 0),
228
+ "errors": refresh_result.get("errors", []),
229
+ "items": refresh_result.get("items", result.get("items", [])),
230
+ }
231
 
232
  @router.delete("/api/accounts")
233
  async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
 
254
  if not access_token:
255
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
256
 
257
+ updates = {
258
+ key: value
259
+ for key, value in {
260
+ "type": body.type,
261
+ "status": body.status,
262
+ "quota": body.quota,
263
+ }.items()
264
+ if value is not None
265
+ }
266
  if not updates:
267
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
268
 
 
289
  require_auth_key(authorization)
290
  return await run_in_threadpool(chatgpt_service.create_response, body.model_dump(mode="python"))
291
 
292
+ # ── CPA multi-pool endpoints ────────────────────────────────────
293
+
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(
301
+ body: CPAPoolCreateRequest,
302
+ authorization: str | None = Header(default=None),
303
+ ):
304
+ require_auth_key(authorization)
305
+ if not body.base_url.strip():
306
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
307
+ if not body.secret_key.strip():
308
+ raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
309
+ pool = cpa_config.add_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(
318
+ pool_id: str,
319
+ body: CPAPoolUpdateRequest,
320
+ authorization: str | None = Header(default=None),
321
+ ):
322
+ require_auth_key(authorization)
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(
330
+ pool_id: str,
331
+ authorization: str | None = Header(default=None),
332
+ ):
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
+ ):
343
+ require_auth_key(authorization)
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
 
376
  @app.get("/{full_path:path}", include_in_schema=False)
 
379
  if asset is not None:
380
  return FileResponse(asset)
381
 
382
+ # Static assets (_next/*) must not fallback to HTML — return 404
383
+ if full_path.strip("/").startswith("_next/"):
384
+ raise HTTPException(status_code=404, detail="Not Found")
385
+
386
  fallback = resolve_web_asset("")
387
  if fallback is None:
388
  raise HTTPException(status_code=404, detail="Not Found")
services/cpa_service.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
85
+ pass
86
+ return []
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()
108
+ return dict(pool)
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/next.config.ts CHANGED
@@ -17,10 +17,12 @@ function readAppVersion() {
17
  const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
18
 
19
  const nextConfig: NextConfig = {
 
20
  env: {
21
  NEXT_PUBLIC_APP_VERSION: appVersion,
22
  },
23
  output: 'export',
 
24
  images: {
25
  unoptimized: true,
26
  },
 
17
  const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
18
 
19
  const nextConfig: NextConfig = {
20
+ allowedDevOrigins: ['127.0.0.1'],
21
  env: {
22
  NEXT_PUBLIC_APP_VERSION: appVersion,
23
  },
24
  output: 'export',
25
+ trailingSlash: true,
26
  images: {
27
  unoptimized: true,
28
  },
web/src/app/image/page.tsx CHANGED
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
  import { ArrowUp, LoaderCircle, MessageSquarePlus, Trash2 } from "lucide-react";
6
  import { toast } from "sonner";
7
 
 
8
  import { Button } from "@/components/ui/button";
9
  import { Input } from "@/components/ui/input";
10
  import {
@@ -96,8 +97,10 @@ export default function ImagePage() {
96
  const [conversations, setConversations] = useState<ImageConversation[]>([]);
97
  const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
98
  const [isLoadingHistory, setIsLoadingHistory] = useState(true);
99
- const [isGenerating, setIsGenerating] = useState(false);
100
  const [availableQuota, setAvailableQuota] = useState("加载中");
 
 
101
  const resultsViewportRef = useRef<HTMLDivElement>(null);
102
  const textareaRef = useRef<HTMLTextAreaElement>(null);
103
 
@@ -106,6 +109,38 @@ export default function ImagePage() {
106
  () => conversations.find((item) => item.id === selectedConversationId) ?? null,
107
  [conversations, selectedConversationId],
108
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  useEffect(() => {
111
  let cancelled = false;
@@ -165,7 +200,7 @@ export default function ImagePage() {
165
  }, [loadQuota]);
166
 
167
  useEffect(() => {
168
- if (!selectedConversation && !isGenerating) {
169
  return;
170
  }
171
 
@@ -173,7 +208,7 @@ export default function ImagePage() {
173
  top: resultsViewportRef.current.scrollHeight,
174
  behavior: "smooth",
175
  });
176
- }, [selectedConversation, isGenerating]);
177
 
178
  const persistConversation = async (conversation: ImageConversation) => {
179
  setConversations((prev) => {
@@ -260,7 +295,7 @@ export default function ImagePage() {
260
  status: "generating",
261
  };
262
 
263
- setIsGenerating(true);
264
  setSelectedConversationId(conversationId);
265
  setImagePrompt("");
266
 
@@ -339,7 +374,7 @@ export default function ImagePage() {
339
  });
340
  toast.error(message);
341
  } finally {
342
- setIsGenerating(false);
343
  }
344
  };
345
 
@@ -379,6 +414,7 @@ export default function ImagePage() {
379
  ) : (
380
  conversations.map((conversation) => {
381
  const active = conversation.id === selectedConversationId;
 
382
  return (
383
  <div
384
  key={conversation.id}
@@ -394,7 +430,10 @@ export default function ImagePage() {
394
  onClick={() => setSelectedConversationId(conversation.id)}
395
  className="block w-full pr-8 text-left"
396
  >
397
- <div className="truncate text-sm font-semibold">{conversation.title}</div>
 
 
 
398
  <div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
399
  {formatConversationTime(conversation.createdAt)}
400
  </div>
@@ -471,14 +510,20 @@ export default function ImagePage() {
471
  {selectedConversation.images.map((image, index) => (
472
  <div key={image.id} className="break-inside-avoid overflow-hidden rounded-[22px]">
473
  {image.status === "success" && image.b64_json ? (
474
- <Image
475
- src={`data:image/png;base64,${image.b64_json}`}
476
- alt={`Generated result ${index + 1}`}
477
- width={1024}
478
- height={1024}
479
- unoptimized
480
- className="block h-auto w-full"
481
- />
 
 
 
 
 
 
482
  ) : image.status === "error" ? (
483
  <div className="flex min-h-[320px] items-center justify-center bg-rose-50 px-6 py-8 text-center text-sm leading-6 text-rose-600">
484
  {image.error || "生成失败"}
@@ -526,9 +571,7 @@ export default function ImagePage() {
526
  onKeyDown={(event) => {
527
  if (event.key === "Enter" && !event.shiftKey) {
528
  event.preventDefault();
529
- if (!isGenerating) {
530
- void handleGenerateImage();
531
- }
532
  }
533
  }}
534
  className="min-h-[148px] resize-none rounded-[32px] border-0 bg-transparent px-6 pt-6 pb-20 text-[15px] leading-7 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0"
@@ -539,6 +582,12 @@ export default function ImagePage() {
539
  <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">
540
  剩余额度 {availableQuota}
541
  </div>
 
 
 
 
 
 
542
  <Select value={imageModel} onValueChange={(value) => setImageModel(value as ImageModel)}>
543
  <SelectTrigger className="h-10 w-[164px] rounded-full border-stone-200 bg-white text-sm font-medium text-stone-700 shadow-none focus-visible:ring-0">
544
  <SelectValue />
@@ -569,11 +618,11 @@ export default function ImagePage() {
569
  <button
570
  type="button"
571
  onClick={() => void handleGenerateImage()}
572
- disabled={isGenerating}
573
  className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-stone-950 text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-300"
574
  aria-label="生成图片"
575
  >
576
- {isGenerating ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
577
  </button>
578
  </div>
579
  </div>
@@ -581,6 +630,14 @@ export default function ImagePage() {
581
  </div>
582
  </div>
583
  </section>
 
 
 
 
 
 
 
 
584
  </>
585
  );
586
  }
 
5
  import { ArrowUp, LoaderCircle, MessageSquarePlus, Trash2 } from "lucide-react";
6
  import { toast } from "sonner";
7
 
8
+ import { ImageLightbox } from "@/components/image-lightbox";
9
  import { Button } from "@/components/ui/button";
10
  import { Input } from "@/components/ui/input";
11
  import {
 
97
  const [conversations, setConversations] = useState<ImageConversation[]>([]);
98
  const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
99
  const [isLoadingHistory, setIsLoadingHistory] = useState(true);
100
+ const [generatingIds, setGeneratingIds] = useState<Set<string>>(new Set());
101
  const [availableQuota, setAvailableQuota] = useState("加载中");
102
+ const [lightboxOpen, setLightboxOpen] = useState(false);
103
+ const [lightboxIndex, setLightboxIndex] = useState(0);
104
  const resultsViewportRef = useRef<HTMLDivElement>(null);
105
  const textareaRef = useRef<HTMLTextAreaElement>(null);
106
 
 
109
  () => conversations.find((item) => item.id === selectedConversationId) ?? null,
110
  [conversations, selectedConversationId],
111
  );
112
+ const isSelectedGenerating = selectedConversationId !== null && generatingIds.has(selectedConversationId);
113
+ const hasAnyGenerating = generatingIds.size > 0;
114
+
115
+ const addGeneratingId = useCallback((id: string) => {
116
+ setGeneratingIds((prev) => new Set(prev).add(id));
117
+ }, []);
118
+ const removeGeneratingId = useCallback((id: string) => {
119
+ setGeneratingIds((prev) => {
120
+ const next = new Set(prev);
121
+ next.delete(id);
122
+ return next;
123
+ });
124
+ }, []);
125
+
126
+ const lightboxImages = useMemo(
127
+ () =>
128
+ (selectedConversation?.images ?? [])
129
+ .filter((img): img is StoredImage & { b64_json: string } => img.status === "success" && !!img.b64_json)
130
+ .map((img) => ({ id: img.id, src: `data:image/png;base64,${img.b64_json}` })),
131
+ [selectedConversation],
132
+ );
133
+
134
+ const openLightbox = useCallback(
135
+ (imageId: string) => {
136
+ const idx = lightboxImages.findIndex((img) => img.id === imageId);
137
+ if (idx >= 0) {
138
+ setLightboxIndex(idx);
139
+ setLightboxOpen(true);
140
+ }
141
+ },
142
+ [lightboxImages],
143
+ );
144
 
145
  useEffect(() => {
146
  let cancelled = false;
 
200
  }, [loadQuota]);
201
 
202
  useEffect(() => {
203
+ if (!selectedConversation && !isSelectedGenerating) {
204
  return;
205
  }
206
 
 
208
  top: resultsViewportRef.current.scrollHeight,
209
  behavior: "smooth",
210
  });
211
+ }, [selectedConversation, isSelectedGenerating]);
212
 
213
  const persistConversation = async (conversation: ImageConversation) => {
214
  setConversations((prev) => {
 
295
  status: "generating",
296
  };
297
 
298
+ addGeneratingId(conversationId);
299
  setSelectedConversationId(conversationId);
300
  setImagePrompt("");
301
 
 
374
  });
375
  toast.error(message);
376
  } finally {
377
+ removeGeneratingId(conversationId);
378
  }
379
  };
380
 
 
414
  ) : (
415
  conversations.map((conversation) => {
416
  const active = conversation.id === selectedConversationId;
417
+ const generating = generatingIds.has(conversation.id);
418
  return (
419
  <div
420
  key={conversation.id}
 
430
  onClick={() => setSelectedConversationId(conversation.id)}
431
  className="block w-full pr-8 text-left"
432
  >
433
+ <div className="flex items-center gap-1.5 truncate text-sm font-semibold">
434
+ {generating && <LoaderCircle className="size-3.5 shrink-0 animate-spin text-stone-400" />}
435
+ <span className="truncate">{conversation.title}</span>
436
+ </div>
437
  <div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
438
  {formatConversationTime(conversation.createdAt)}
439
  </div>
 
510
  {selectedConversation.images.map((image, index) => (
511
  <div key={image.id} className="break-inside-avoid overflow-hidden rounded-[22px]">
512
  {image.status === "success" && image.b64_json ? (
513
+ <button
514
+ type="button"
515
+ onClick={() => openLightbox(image.id)}
516
+ className="group block w-full cursor-zoom-in"
517
+ >
518
+ <Image
519
+ src={`data:image/png;base64,${image.b64_json}`}
520
+ alt={`Generated result ${index + 1}`}
521
+ width={1024}
522
+ height={1024}
523
+ unoptimized
524
+ className="block h-auto w-full transition duration-200 group-hover:brightness-90"
525
+ />
526
+ </button>
527
  ) : image.status === "error" ? (
528
  <div className="flex min-h-[320px] items-center justify-center bg-rose-50 px-6 py-8 text-center text-sm leading-6 text-rose-600">
529
  {image.error || "生成失败"}
 
571
  onKeyDown={(event) => {
572
  if (event.key === "Enter" && !event.shiftKey) {
573
  event.preventDefault();
574
+ void handleGenerateImage();
 
 
575
  }
576
  }}
577
  className="min-h-[148px] resize-none rounded-[32px] border-0 bg-transparent px-6 pt-6 pb-20 text-[15px] leading-7 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0"
 
582
  <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">
583
  剩余额度 {availableQuota}
584
  </div>
585
+ {hasAnyGenerating && (
586
+ <div className="flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
587
+ <LoaderCircle className="size-3 animate-spin" />
588
+ {generatingIds.size} 个任务进行中
589
+ </div>
590
+ )}
591
  <Select value={imageModel} onValueChange={(value) => setImageModel(value as ImageModel)}>
592
  <SelectTrigger className="h-10 w-[164px] rounded-full border-stone-200 bg-white text-sm font-medium text-stone-700 shadow-none focus-visible:ring-0">
593
  <SelectValue />
 
618
  <button
619
  type="button"
620
  onClick={() => void handleGenerateImage()}
621
+ disabled={!imagePrompt.trim()}
622
  className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-stone-950 text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-300"
623
  aria-label="生成图片"
624
  >
625
+ <ArrowUp className="size-4" />
626
  </button>
627
  </div>
628
  </div>
 
630
  </div>
631
  </div>
632
  </section>
633
+
634
+ <ImageLightbox
635
+ images={lightboxImages}
636
+ currentIndex={lightboxIndex}
637
+ open={lightboxOpen}
638
+ onOpenChange={setLightboxOpen}
639
+ onIndexChange={setLightboxIndex}
640
+ />
641
  </>
642
  );
643
  }
web/src/app/settings/page.tsx ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
17
+ } from "lucide-react";
18
+ import { toast } from "sonner";
19
+
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,
27
+ DialogDescription,
28
+ DialogFooter,
29
+ DialogHeader,
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("");
80
+ const [formBaseUrl, setFormBaseUrl] = useState("");
81
+ const [formSecretKey, setFormSecretKey] = 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("");
155
+ setFormBaseUrl("");
156
+ setFormSecretKey("");
157
+ setShowSecret(false);
158
+ setDialogOpen(true);
159
+ };
160
+
161
+ const openEditDialog = (pool: CPAPool) => {
162
+ setEditingPool(pool);
163
+ setFormName(pool.name);
164
+ setFormBaseUrl(pool.base_url);
165
+ setFormSecretKey("");
166
+ setShowSecret(false);
167
+ setDialogOpen(true);
168
+ };
169
+
170
+ const handleSave = async () => {
171
+ if (!formBaseUrl.trim()) {
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(),
193
+ base_url: formBaseUrl.trim(),
194
+ secret_key: formSecretKey.trim(),
195
+ });
196
+ setPools(data.pools);
197
+ toast.success("连接已添加");
198
+ }
199
+ setDialogOpen(false);
200
+ } catch (error) {
201
+ toast.error(error instanceof Error ? error.message : "保存失败");
202
+ } finally {
203
+ setIsSaving(false);
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 {
216
+ setDeletingId(null);
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">
300
+ <div className="space-y-1">
301
+ <div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Settings</div>
302
+ <h1 className="text-2xl font-semibold tracking-tight">设置</h1>
303
+ </div>
304
+ </section>
305
+
306
+ <section className="space-y-6">
307
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
308
+ <CardContent className="space-y-6 p-6">
309
+ <div className="flex items-start justify-between">
310
+ <div className="flex items-center gap-3">
311
+ <div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
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>
327
+
328
+ {isLoading ? (
329
+ <div className="flex items-center justify-center py-10">
330
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
331
+ </div>
332
+ ) : pools.length === 0 ? (
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"
356
+ onClick={() => openEditDialog(pool)}
357
+ disabled={isBusy}
358
+ title="编辑"
359
+ >
360
+ <Pencil className="size-4" />
361
+ </button>
362
+ <button
363
+ type="button"
364
+ className="rounded-lg p-2 text-stone-400 transition hover:bg-rose-50 hover:text-rose-500"
365
+ onClick={() => void handleDelete(pool)}
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
+ })}
426
+ </div>
427
+ )}
428
+
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">
451
+ <div className="space-y-2">
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
+ />
459
+ </div>
460
+ <div className="space-y-2">
461
+ <label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
462
+ <Link2 className="size-3.5" />
463
+ CPA 地址
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
+ />
471
+ </div>
472
+ <div className="space-y-2">
473
+ <label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
474
+ <Unplug className="size-3.5" />
475
+ Management Secret Key
476
+ </label>
477
+ <div className="relative">
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>
492
+ </div>
493
+ </div>
494
+ </div>
495
+ <DialogFooter className="pt-2">
496
+ <Button
497
+ variant="secondary"
498
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
499
+ onClick={() => setDialogOpen(false)}
500
+ disabled={isSaving}
501
+ >
502
+ 取消
503
+ </Button>
504
+ <Button
505
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
506
+ onClick={() => void handleSave()}
507
+ disabled={isSaving}
508
+ >
509
+ {isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
510
+ {editingPool ? "保存修改" : "添加"}
511
+ </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/components/image-lightbox.tsx ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect } from "react";
4
+ import Image from "next/image";
5
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
6
+ import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
7
+
8
+ import { cn } from "@/lib/utils";
9
+
10
+ type LightboxImage = {
11
+ id: string;
12
+ src: string;
13
+ };
14
+
15
+ type ImageLightboxProps = {
16
+ images: LightboxImage[];
17
+ currentIndex: number;
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ onIndexChange: (index: number) => void;
21
+ };
22
+
23
+ export function ImageLightbox({
24
+ images,
25
+ currentIndex,
26
+ open,
27
+ onOpenChange,
28
+ onIndexChange,
29
+ }: ImageLightboxProps) {
30
+ const current = images[currentIndex];
31
+ const hasPrev = currentIndex > 0;
32
+ const hasNext = currentIndex < images.length - 1;
33
+
34
+ const goPrev = useCallback(() => {
35
+ if (hasPrev) onIndexChange(currentIndex - 1);
36
+ }, [hasPrev, currentIndex, onIndexChange]);
37
+
38
+ const goNext = useCallback(() => {
39
+ if (hasNext) onIndexChange(currentIndex + 1);
40
+ }, [hasNext, currentIndex, onIndexChange]);
41
+
42
+ useEffect(() => {
43
+ if (!open) return;
44
+
45
+ const handleKeyDown = (e: KeyboardEvent) => {
46
+ if (e.key === "ArrowLeft") {
47
+ e.preventDefault();
48
+ goPrev();
49
+ } else if (e.key === "ArrowRight") {
50
+ e.preventDefault();
51
+ goNext();
52
+ }
53
+ };
54
+
55
+ window.addEventListener("keydown", handleKeyDown);
56
+ return () => window.removeEventListener("keydown", handleKeyDown);
57
+ }, [open, goPrev, goNext]);
58
+
59
+ const handleDownload = useCallback(() => {
60
+ if (!current) return;
61
+ const link = document.createElement("a");
62
+ link.href = current.src;
63
+ link.download = `image-${current.id}.png`;
64
+ link.click();
65
+ }, [current]);
66
+
67
+ if (!current) return null;
68
+
69
+ return (
70
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
71
+ <DialogPrimitive.Portal>
72
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
73
+ <DialogPrimitive.Content
74
+ className="fixed inset-0 z-50 flex items-center justify-center outline-none"
75
+ onPointerDownOutside={(e) => e.preventDefault()}
76
+ >
77
+ <DialogPrimitive.Title className="sr-only">
78
+ 图片预览
79
+ </DialogPrimitive.Title>
80
+
81
+ {/* toolbar */}
82
+ <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
83
+ {images.length > 1 && (
84
+ <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
85
+ {currentIndex + 1} / {images.length}
86
+ </span>
87
+ )}
88
+ <button
89
+ type="button"
90
+ onClick={handleDownload}
91
+ className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70"
92
+ aria-label="下载图片"
93
+ >
94
+ <Download className="size-4" />
95
+ </button>
96
+ <DialogPrimitive.Close className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70">
97
+ <X className="size-4" />
98
+ <span className="sr-only">关闭</span>
99
+ </DialogPrimitive.Close>
100
+ </div>
101
+
102
+ {/* prev */}
103
+ {hasPrev && (
104
+ <button
105
+ type="button"
106
+ onClick={goPrev}
107
+ className="absolute left-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
108
+ aria-label="上一张"
109
+ >
110
+ <ChevronLeft className="size-5" />
111
+ </button>
112
+ )}
113
+
114
+ {/* image */}
115
+ <div
116
+ className="flex max-h-[90vh] max-w-[90vw] items-center justify-center"
117
+ onClick={() => onOpenChange(false)}
118
+ >
119
+ <Image
120
+ src={current.src}
121
+ alt=""
122
+ width={1536}
123
+ height={1536}
124
+ unoptimized
125
+ className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
126
+ onClick={(e) => e.stopPropagation()}
127
+ draggable={false}
128
+ />
129
+ </div>
130
+
131
+ {/* next */}
132
+ {hasNext && (
133
+ <button
134
+ type="button"
135
+ onClick={goNext}
136
+ className="absolute right-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
137
+ aria-label="下一张"
138
+ >
139
+ <ChevronRight className="size-5" />
140
+ </button>
141
+ )}
142
+ </DialogPrimitive.Content>
143
+ </DialogPrimitive.Portal>
144
+ </DialogPrimitive.Root>
145
+ );
146
+ }
web/src/components/top-nav.tsx CHANGED
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
11
  const navItems = [
12
  { href: "/image", label: "画图" },
13
  { href: "/accounts", label: "号池管理" },
 
14
  ];
15
 
16
  export function TopNav() {
 
11
  const navItems = [
12
  { href: "/image", label: "画图" },
13
  { href: "/accounts", label: "号池管理" },
14
+ { href: "/settings", label: "设置" },
15
  ];
16
 
17
  export function TopNav() {
web/src/lib/api.ts CHANGED
@@ -116,3 +116,73 @@ export async function generateImage(prompt: string, model: ImageModel = "gpt-ima
116
  },
117
  );
118
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  },
117
  );
118
  }
119
+
120
+ // ── CPA (CLIProxyAPI) ──────────────────────────────────────────────
121
+
122
+ export type CPAPool = {
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,
156
+ });
157
+ }
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",
165
+ body: updates,
166
+ });
167
+ }
168
+
169
+ export async function deleteCPAPool(poolId: string) {
170
+ return httpRequest<{ pools: CPAPool[] }>(`/api/cpa/pools/${poolId}`, {
171
+ method: "DELETE",
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
+ }
web/src/lib/request.ts CHANGED
@@ -30,8 +30,14 @@ request.interceptors.response.use(
30
  const status = error.response?.status;
31
  const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
32
  if (status === 401 && shouldRedirect && typeof window !== "undefined") {
33
- await clearStoredAuthKey();
34
- window.location.href = "/login";
 
 
 
 
 
 
35
  }
36
 
37
  const payload = error.response?.data;
 
30
  const status = error.response?.status;
31
  const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
32
  if (status === 401 && shouldRedirect && typeof window !== "undefined") {
33
+ // Avoid redirect loop — only redirect if not already on /login
34
+ if (!window.location.pathname.startsWith("/login")) {
35
+ await clearStoredAuthKey();
36
+ window.location.replace("/login");
37
+ // Return a never-resolving promise to prevent further error handling
38
+ // while the browser navigates away
39
+ return new Promise(() => {});
40
+ }
41
  }
42
 
43
  const payload = error.response?.data;