Merge branch 'basketikun:main' into main
Browse files- services/api.py +145 -4
- services/cpa_service.py +314 -0
- web/next.config.ts +2 -0
- web/src/app/image/page.tsx +76 -19
- web/src/app/settings/page.tsx +647 -0
- web/src/components/image-lightbox.tsx +146 -0
- web/src/components/top-nav.tsx +1 -0
- web/src/lib/api.ts +70 -0
- web/src/lib/request.ts +8 -2
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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 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 && !
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
|
|
@@ -173,7 +208,7 @@ export default function ImagePage() {
|
|
| 173 |
top: resultsViewportRef.current.scrollHeight,
|
| 174 |
behavior: "smooth",
|
| 175 |
});
|
| 176 |
-
}, [selectedConversation,
|
| 177 |
|
| 178 |
const persistConversation = async (conversation: ImageConversation) => {
|
| 179 |
setConversations((prev) => {
|
|
@@ -260,7 +295,7 @@ export default function ImagePage() {
|
|
| 260 |
status: "generating",
|
| 261 |
};
|
| 262 |
|
| 263 |
-
|
| 264 |
setSelectedConversationId(conversationId);
|
| 265 |
setImagePrompt("");
|
| 266 |
|
|
@@ -339,7 +374,7 @@ export default function ImagePage() {
|
|
| 339 |
});
|
| 340 |
toast.error(message);
|
| 341 |
} finally {
|
| 342 |
-
|
| 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">
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 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 |
-
|
| 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={
|
| 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 |
-
|
| 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 |
-
|
| 34 |
-
window.location.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|