feat: optimize frontend batch processing logic

This commit is contained in:
Chenyme committed 2026-02-03 23:15:13 +08:00
1 parent 8e23fdf9c7
commit b11aace053
21 files changed
+2094 -756

No files matched your search

+729 -45
View File
@@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, StreamingResponse
from app.core.auth import verify_api_key, verify_app_key
from app.core.config import config, get_config
from app.core.batch_tasks import create_task, get_task, expire_task
from app.core.storage import get_storage, LocalStorage, RedisStorage, SQLStorage
import os
from pathlib import Path
import aiofiles
import asyncio
import orjson
from app.core.logger import logger
@@ -26,6 +28,65 @@ async def render_template(filename: str):
return HTMLResponse(content)
def _sse_event(payload: dict) -> str:
return f"data: {orjson.dumps(payload).decode()}\n\n"
def _verify_stream_api_key(request: Request) -> None:
api_key = get_config("app.api_key", "")
if not api_key:
return
key = request.query_params.get("api_key")
if key != api_key:
raise HTTPException(status_code=401, detail="Invalid authentication token")
@router.get("/api/v1/admin/batch/{task_id}/stream")
async def stream_batch(task_id: str, request: Request):
_verify_stream_api_key(request)
task = get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
async def event_stream():
queue = task.attach()
try:
yield _sse_event({"type": "snapshot", **task.snapshot()})
final = task.final_event()
if final:
yield _sse_event(final)
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n"
final = task.final_event()
if final:
yield _sse_event(final)
return
continue
yield _sse_event(event)
if event.get("type") in ("done", "error", "cancelled"):
return
finally:
task.detach(queue)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/api/v1/admin/batch/{task_id}/cancel", dependencies=[Depends(verify_api_key)])
async def cancel_batch(task_id: str):
task = get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.cancel()
return {"status": "success"}
@router.get("/admin", response_class=HTMLResponse, include_in_schema=False)
async def admin_login_page():
"""管理后台登录页"""
@@ -118,6 +179,7 @@ async def update_tokens_api(data: dict):
async def refresh_tokens_api(data: dict):
"""刷新 Token 状态"""
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
try:
mgr = await get_token_manager()
@@ -130,22 +192,158 @@ async def refresh_tokens_api(data: dict):
if not tokens:
raise HTTPException(status_code=400, detail="No tokens provided")
unique_tokens = list(set(tokens))
# 去重并保持顺序
unique_tokens = list(dict.fromkeys(tokens))
sem = asyncio.Semaphore(10)
# 最大数量限制
max_tokens = get_config("performance.usage_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(unique_tokens)
if len(unique_tokens) > max_tokens:
unique_tokens = unique_tokens[:max_tokens]
truncated = True
logger.warning(
f"Usage refresh: truncated from {original_count} to {max_tokens} tokens"
)
# 批量执行配置
max_concurrent = get_config("performance.usage_max_concurrent", 25)
batch_size = get_config("performance.usage_batch_size", 50)
async def _refresh_one(t):
async with sem:
return t, await mgr.sync_usage(
return await mgr.sync_usage(
t, "grok-3", consume_on_fail=False, is_usage=False
)
raw_results = await run_in_batches(
unique_tokens, _refresh_one, max_concurrent=max_concurrent, batch_size=batch_size
)
results = {}
for token, res in raw_results.items():
if res.get("ok"):
results[token] = res.get("data", False)
else:
results[token] = False
response = {"status": "success", "results": results}
if truncated:
response["warning"] = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/admin/tokens/refresh/async", dependencies=[Depends(verify_api_key)]
)
async def refresh_tokens_api_async(data: dict):
"""刷新 Token 状态(异步批量 + SSE 进度)"""
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
mgr = await get_token_manager()
tokens: list[str] = []
if isinstance(data.get("token"), str) and data["token"].strip():
tokens.append(data["token"].strip())
if isinstance(data.get("tokens"), list):
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
if not tokens:
raise HTTPException(status_code=400, detail="No tokens provided")
unique_tokens = list(dict.fromkeys(tokens))
max_tokens = get_config("performance.usage_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(unique_tokens)
if len(unique_tokens) > max_tokens:
unique_tokens = unique_tokens[:max_tokens]
truncated = True
logger.warning(
f"Usage refresh: truncated from {original_count} to {max_tokens} tokens"
)
max_concurrent = get_config("performance.usage_max_concurrent", 25)
batch_size = get_config("performance.usage_batch_size", 50)
task = create_task(len(unique_tokens))
async def _run():
try:
async def _refresh_one(t: str):
return await mgr.sync_usage(
t, "grok-3", consume_on_fail=False, is_usage=False
)
results_list = await asyncio.gather(*[_refresh_one(t) for t in unique_tokens])
results = dict(results_list)
async def _on_item(item: str, res: dict):
task.record(bool(res.get("ok")))
return {"status": "success", "results": results}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raw_results = await run_in_batches(
unique_tokens,
_refresh_one,
max_concurrent=max_concurrent,
batch_size=batch_size,
on_item=_on_item,
should_cancel=lambda: task.cancelled,
)
if task.cancelled:
task.finish_cancelled()
return
results: dict[str, bool] = {}
ok_count = 0
fail_count = 0
for token, res in raw_results.items():
if res.get("ok") and res.get("data") is True:
ok_count += 1
results[token] = True
else:
fail_count += 1
results[token] = False
await mgr._save()
result = {
"status": "success",
"summary": {
"total": len(unique_tokens),
"ok": ok_count,
"fail": fail_count,
},
"results": results,
}
warning = None
if truncated:
warning = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
task.finish(result, warning=warning)
except Exception as e:
task.fail_task(str(e))
finally:
asyncio.create_task(expire_task(task.id, 300))
asyncio.create_task(_run())
return {
"status": "success",
"task_id": task.id,
"total": len(unique_tokens),
}
@router.post("/api/v1/admin/tokens/nsfw/enable", dependencies=[Depends(verify_api_key)])
@@ -199,7 +397,7 @@ async def enable_nsfw_api(data: dict):
# 批量执行配置
max_concurrent = get_config("performance.nsfw_max_concurrent", 10)
batch_size = get_config("performance.admin_nsfw_batch_size", 50)
batch_size = get_config("performance.nsfw_batch_size", 50)
# 定义 worker
async def _enable(token: str):
@@ -257,6 +455,129 @@ async def enable_nsfw_api(data: dict):
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/admin/tokens/nsfw/enable/async", dependencies=[Depends(verify_api_key)]
)
async def enable_nsfw_api_async(data: dict):
"""批量开启 NSFW (Unhinged) 模式(异步批量 + SSE 进度)"""
from app.services.grok.nsfw import NSFWService
from app.services.grok.batch import run_in_batches
from app.services.token.manager import get_token_manager
mgr = await get_token_manager()
nsfw_service = NSFWService()
tokens: list[str] = []
if isinstance(data.get("token"), str) and data["token"].strip():
tokens.append(data["token"].strip())
if isinstance(data.get("tokens"), list):
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
if not tokens:
for pool_name, pool in mgr.pools.items():
for info in pool.list():
raw = info.token[4:] if info.token.startswith("sso=") else info.token
tokens.append(raw)
if not tokens:
raise HTTPException(status_code=400, detail="No tokens available")
unique_tokens = list(dict.fromkeys(tokens))
max_tokens = get_config("performance.nsfw_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(unique_tokens)
if len(unique_tokens) > max_tokens:
unique_tokens = unique_tokens[:max_tokens]
truncated = True
logger.warning(
f"NSFW enable: truncated from {original_count} to {max_tokens} tokens"
)
max_concurrent = get_config("performance.nsfw_max_concurrent", 10)
batch_size = get_config("performance.nsfw_batch_size", 50)
task = create_task(len(unique_tokens))
async def _run():
try:
async def _enable(token: str):
result = await nsfw_service.enable(token)
if result.success:
await mgr.add_tag(token, "nsfw")
return {
"success": result.success,
"http_status": result.http_status,
"grpc_status": result.grpc_status,
"grpc_message": result.grpc_message,
"error": result.error,
}
async def _on_item(item: str, res: dict):
ok = bool(res.get("ok") and res.get("data", {}).get("success"))
task.record(ok)
raw_results = await run_in_batches(
unique_tokens,
_enable,
max_concurrent=max_concurrent,
batch_size=batch_size,
on_item=_on_item,
should_cancel=lambda: task.cancelled,
)
if task.cancelled:
task.finish_cancelled()
return
results = {}
ok_count = 0
fail_count = 0
for token, res in raw_results.items():
masked = f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token
if res.get("ok") and res.get("data", {}).get("success"):
ok_count += 1
results[masked] = res.get("data", {})
else:
fail_count += 1
results[masked] = res.get("data") or {"error": res.get("error")}
await mgr._save()
result = {
"status": "success",
"summary": {
"total": len(unique_tokens),
"ok": ok_count,
"fail": fail_count,
},
"results": results,
}
warning = None
if truncated:
warning = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
task.finish(result, warning=warning)
except Exception as e:
task.fail_task(str(e))
finally:
asyncio.create_task(expire_task(task.id, 300))
asyncio.create_task(_run())
return {
"status": "success",
"task_id": task.id,
"total": len(unique_tokens),
}
@router.get("/admin/cache", response_class=HTMLResponse, include_in_schema=False)
async def admin_cache_page():
"""缓存管理页"""
@@ -268,6 +589,7 @@ async def get_cache_stats_api(request: Request):
"""获取缓存统计"""
from app.services.grok.assets import DownloadService, ListService
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
try:
dl_service = DownloadService()
@@ -312,13 +634,28 @@ async def get_cache_stats_api(request: Request):
}
online_details = []
account_map = {a["token"]: a for a in accounts}
batch_size = get_config("performance.admin_assets_batch_size", 10)
max_concurrent = get_config("performance.assets_max_concurrent", 25)
batch_size = get_config("performance.assets_batch_size", 10)
try:
max_concurrent = int(max_concurrent)
except Exception:
max_concurrent = 25
try:
batch_size = int(batch_size)
except Exception:
batch_size = 10
max_concurrent = max(1, max_concurrent)
batch_size = max(1, batch_size)
max_tokens = get_config("performance.assets_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = 0
async def _fetch_assets(token: str):
list_service = ListService()
try:
@@ -330,8 +667,8 @@ async def get_cache_stats_api(request: Request):
account = account_map.get(token)
try:
count = await _fetch_assets(token)
return (
{
return {
"detail": {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": count,
@@ -340,11 +677,11 @@ async def get_cache_stats_api(request: Request):
if account
else None,
},
count,
)
"count": count,
}
except Exception as e:
return (
{
return {
"detail": {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
@@ -353,19 +690,40 @@ async def get_cache_stats_api(request: Request):
if account
else None,
},
0,
)
"count": 0,
}
if selected_tokens:
selected_tokens = list(dict.fromkeys(selected_tokens))
original_count = len(selected_tokens)
if len(selected_tokens) > max_tokens:
selected_tokens = selected_tokens[:max_tokens]
truncated = True
total = 0
for i in range(0, len(selected_tokens), batch_size):
chunk = selected_tokens[i : i + batch_size]
results = await asyncio.gather(
*[_fetch_detail(token) for token in chunk]
)
for detail, count in results:
raw_results = await run_in_batches(
selected_tokens,
_fetch_detail,
max_concurrent=max_concurrent,
batch_size=batch_size,
)
for token, res in raw_results.items():
if res.get("ok"):
data = res.get("data", {})
detail = data.get("detail")
total += data.get("count", 0)
else:
account = account_map.get(token)
detail = {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
"status": f"error: {res.get('error')}",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
}
if detail:
online_details.append(detail)
total += count
online_stats = {
"count": total,
"status": "ok" if selected_tokens else "no_token",
@@ -375,15 +733,35 @@ async def get_cache_stats_api(request: Request):
scope = "selected"
elif scope == "all":
total = 0
tokens = [account["token"] for account in accounts]
for i in range(0, len(tokens), batch_size):
chunk = tokens[i : i + batch_size]
results = await asyncio.gather(
*[_fetch_detail(token) for token in chunk]
)
for detail, count in results:
tokens = list(dict.fromkeys([account["token"] for account in accounts]))
original_count = len(tokens)
if len(tokens) > max_tokens:
tokens = tokens[:max_tokens]
truncated = True
raw_results = await run_in_batches(
tokens,
_fetch_detail,
max_concurrent=max_concurrent,
batch_size=batch_size,
)
for token, res in raw_results.items():
if res.get("ok"):
data = res.get("data", {})
detail = data.get("detail")
total += data.get("count", 0)
else:
account = account_map.get(token)
detail = {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
"status": f"error: {res.get('error')}",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
}
if detail:
online_details.append(detail)
total += count
online_stats = {
"count": total,
"status": "ok" if accounts else "no_token",
@@ -424,7 +802,7 @@ async def get_cache_stats_api(request: Request):
"last_asset_clear_at": None,
}
return {
response = {
"local_image": image_stats,
"local_video": video_stats,
"online": online_stats,
@@ -432,10 +810,177 @@ async def get_cache_stats_api(request: Request):
"online_scope": scope or "none",
"online_details": online_details,
}
if truncated:
response["warning"] = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/admin/cache/online/load/async", dependencies=[Depends(verify_api_key)]
)
async def load_online_cache_api_async(data: dict):
"""在线资产统计(异步批量 + SSE 进度)"""
from app.services.grok.assets import DownloadService, ListService
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
mgr = await get_token_manager()
# 账号列表
accounts = []
for pool_name, pool in mgr.pools.items():
for info in pool.list():
raw_token = info.token[4:] if info.token.startswith("sso=") else info.token
masked = (
f"{raw_token[:8]}...{raw_token[-16:]}"
if len(raw_token) > 24
else raw_token
)
accounts.append(
{
"token": raw_token,
"token_masked": masked,
"pool": pool_name,
"status": info.status,
"last_asset_clear_at": info.last_asset_clear_at,
}
)
account_map = {a["token"]: a for a in accounts}
tokens = data.get("tokens")
scope = data.get("scope")
selected_tokens: list[str] = []
if isinstance(tokens, list):
selected_tokens = [str(t).strip() for t in tokens if str(t).strip()]
if not selected_tokens and scope == "all":
selected_tokens = [account["token"] for account in accounts]
scope = "all"
elif selected_tokens:
scope = "selected"
else:
raise HTTPException(status_code=400, detail="No tokens provided")
selected_tokens = list(dict.fromkeys(selected_tokens))
max_tokens = get_config("performance.assets_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(selected_tokens)
if len(selected_tokens) > max_tokens:
selected_tokens = selected_tokens[:max_tokens]
truncated = True
max_concurrent = get_config("performance.assets_max_concurrent", 25)
batch_size = get_config("performance.assets_batch_size", 10)
task = create_task(len(selected_tokens))
async def _run():
try:
dl_service = DownloadService()
image_stats = dl_service.get_stats("image")
video_stats = dl_service.get_stats("video")
async def _fetch_detail(token: str):
account = account_map.get(token)
list_service = ListService()
try:
count = await list_service.count(token)
detail = {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": count,
"status": "ok",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
}
return {"ok": True, "detail": detail, "count": count}
except Exception as e:
detail = {
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
"status": f"error: {str(e)}",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
}
return {"ok": False, "detail": detail, "count": 0}
finally:
await list_service.close()
async def _on_item(item: str, res: dict):
ok = bool(res.get("data", {}).get("ok"))
task.record(ok)
raw_results = await run_in_batches(
selected_tokens,
_fetch_detail,
max_concurrent=max_concurrent,
batch_size=batch_size,
on_item=_on_item,
should_cancel=lambda: task.cancelled,
)
if task.cancelled:
task.finish_cancelled()
return
online_details = []
total = 0
for token, res in raw_results.items():
data = res.get("data", {})
detail = data.get("detail")
if detail:
online_details.append(detail)
total += data.get("count", 0)
online_stats = {
"count": total,
"status": "ok" if selected_tokens else "no_token",
"token": None,
"last_asset_clear_at": None,
}
result = {
"local_image": image_stats,
"local_video": video_stats,
"online": online_stats,
"online_accounts": accounts,
"online_scope": scope or "none",
"online_details": online_details,
}
warning = None
if truncated:
warning = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
task.finish(result, warning=warning)
except Exception as e:
task.fail_task(str(e))
finally:
asyncio.create_task(expire_task(task.id, 300))
asyncio.create_task(_run())
return {
"status": "success",
"task_id": task.id,
"total": len(selected_tokens),
}
@router.post("/api/v1/admin/cache/clear", dependencies=[Depends(verify_api_key)])
async def clear_local_cache_api(data: dict):
"""清理本地缓存"""
@@ -493,6 +1038,7 @@ async def clear_online_cache_api(data: dict):
"""清理在线缓存"""
from app.services.grok.assets import DeleteService
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
delete_service = None
try:
@@ -505,29 +1051,61 @@ async def clear_online_cache_api(data: dict):
if not token_list:
raise HTTPException(status_code=400, detail="No tokens provided")
# 去重并保持顺序
token_list = list(dict.fromkeys(token_list))
# 最大数量限制
max_tokens = get_config("performance.assets_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(token_list)
if len(token_list) > max_tokens:
token_list = token_list[:max_tokens]
truncated = True
results = {}
batch_size = get_config("performance.admin_assets_batch_size", 10)
max_concurrent = get_config("performance.assets_max_concurrent", 25)
batch_size = get_config("performance.assets_batch_size", 10)
try:
max_concurrent = int(max_concurrent)
except Exception:
max_concurrent = 25
try:
batch_size = int(batch_size)
except Exception:
batch_size = 10
max_concurrent = max(1, max_concurrent)
batch_size = max(1, batch_size)
async def _clear_one(t: str):
try:
result = await delete_service.delete_all(t)
await mgr.mark_asset_clear(t)
return t, {"status": "success", "result": result}
return {"status": "success", "result": result}
except Exception as e:
return t, {"status": "error", "error": str(e)}
return {"status": "error", "error": str(e)}
for i in range(0, len(token_list), batch_size):
chunk = token_list[i : i + batch_size]
res_list = await asyncio.gather(*[_clear_one(t) for t in chunk])
for t, res in res_list:
results[t] = res
raw_results = await run_in_batches(
token_list,
_clear_one,
max_concurrent=max_concurrent,
batch_size=batch_size,
)
for token, res in raw_results.items():
if res.get("ok"):
results[token] = res.get("data", {})
else:
results[token] = {"status": "error", "error": res.get("error")}
return {"status": "success", "results": results}
response = {"status": "success", "results": results}
if truncated:
response["warning"] = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
return response
token = data.get("token") or mgr.get_token()
if not token:
@@ -543,3 +1121,109 @@ async def clear_online_cache_api(data: dict):
finally:
if delete_service:
await delete_service.close()
@router.post(
"/api/v1/admin/cache/online/clear/async", dependencies=[Depends(verify_api_key)]
)
async def clear_online_cache_api_async(data: dict):
"""清理在线缓存(异步批量 + SSE 进度)"""
from app.services.grok.assets import DeleteService
from app.services.token.manager import get_token_manager
from app.services.grok.batch import run_in_batches
mgr = await get_token_manager()
tokens = data.get("tokens")
if not isinstance(tokens, list):
raise HTTPException(status_code=400, detail="No tokens provided")
token_list = [t.strip() for t in tokens if isinstance(t, str) and t.strip()]
if not token_list:
raise HTTPException(status_code=400, detail="No tokens provided")
token_list = list(dict.fromkeys(token_list))
max_tokens = get_config("performance.assets_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
truncated = False
original_count = len(token_list)
if len(token_list) > max_tokens:
token_list = token_list[:max_tokens]
truncated = True
max_concurrent = get_config("performance.assets_max_concurrent", 25)
batch_size = get_config("performance.assets_batch_size", 10)
task = create_task(len(token_list))
async def _run():
delete_service = DeleteService()
try:
async def _clear_one(t: str):
try:
result = await delete_service.delete_all(t)
await mgr.mark_asset_clear(t)
return {"ok": True, "result": result}
except Exception as e:
return {"ok": False, "error": str(e)}
async def _on_item(item: str, res: dict):
ok = bool(res.get("data", {}).get("ok"))
task.record(ok)
raw_results = await run_in_batches(
token_list,
_clear_one,
max_concurrent=max_concurrent,
batch_size=batch_size,
on_item=_on_item,
should_cancel=lambda: task.cancelled,
)
if task.cancelled:
task.finish_cancelled()
return
results = {}
ok_count = 0
fail_count = 0
for token, res in raw_results.items():
data = res.get("data", {})
if data.get("ok"):
ok_count += 1
results[token] = {"status": "success", "result": data.get("result")}
else:
fail_count += 1
results[token] = {"status": "error", "error": data.get("error")}
result = {
"status": "success",
"summary": {
"total": len(token_list),
"ok": ok_count,
"fail": fail_count,
},
"results": results,
}
warning = None
if truncated:
warning = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
task.finish(result, warning=warning)
except Exception as e:
task.fail_task(str(e))
finally:
await delete_service.close()
asyncio.create_task(expire_task(task.id, 300))
asyncio.create_task(_run())
return {
"status": "success",
"task_id": task.id,
"total": len(token_list),
}
+151
View File
@@ -0,0 +1,151 @@
"""
Batch task manager for admin batch operations (SSE progress).
"""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Any, Dict, List, Optional
class BatchTask:
def __init__(self, total: int):
self.id = uuid.uuid4().hex
self.total = int(total)
self.processed = 0
self.ok = 0
self.fail = 0
self.status = "running"
self.warning: Optional[str] = None
self.result: Optional[Dict[str, Any]] = None
self.error: Optional[str] = None
self.created_at = time.time()
self._queues: List[asyncio.Queue] = []
self._final_event: Optional[Dict[str, Any]] = None
self.cancelled = False
def snapshot(self) -> Dict[str, Any]:
return {
"task_id": self.id,
"status": self.status,
"total": self.total,
"processed": self.processed,
"ok": self.ok,
"fail": self.fail,
"warning": self.warning,
}
def attach(self) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=200)
self._queues.append(q)
return q
def detach(self, q: asyncio.Queue) -> None:
if q in self._queues:
self._queues.remove(q)
def _publish(self, event: Dict[str, Any]) -> None:
for q in list(self._queues):
try:
q.put_nowait(event)
except Exception:
# Drop if queue is full or closed
pass
def record(self, ok: bool, *, item: Any = None, detail: Any = None, error: str = "") -> None:
self.processed += 1
if ok:
self.ok += 1
else:
self.fail += 1
event: Dict[str, Any] = {
"type": "progress",
"task_id": self.id,
"total": self.total,
"processed": self.processed,
"ok": self.ok,
"fail": self.fail,
}
if item is not None:
event["item"] = item
if detail is not None:
event["detail"] = detail
if error:
event["error"] = error
self._publish(event)
def finish(self, result: Dict[str, Any], *, warning: Optional[str] = None) -> None:
self.status = "done"
self.result = result
self.warning = warning
event = {
"type": "done",
"task_id": self.id,
"total": self.total,
"processed": self.processed,
"ok": self.ok,
"fail": self.fail,
"warning": self.warning,
"result": result,
}
self._final_event = event
self._publish(event)
def fail_task(self, error: str) -> None:
self.status = "error"
self.error = error
event = {
"type": "error",
"task_id": self.id,
"total": self.total,
"processed": self.processed,
"ok": self.ok,
"fail": self.fail,
"error": error,
}
self._final_event = event
self._publish(event)
def cancel(self) -> None:
self.cancelled = True
def finish_cancelled(self) -> None:
self.status = "cancelled"
event = {
"type": "cancelled",
"task_id": self.id,
"total": self.total,
"processed": self.processed,
"ok": self.ok,
"fail": self.fail,
}
self._final_event = event
self._publish(event)
def final_event(self) -> Optional[Dict[str, Any]]:
return self._final_event
_TASKS: Dict[str, BatchTask] = {}
def create_task(total: int) -> BatchTask:
task = BatchTask(total)
_TASKS[task.id] = task
return task
def get_task(task_id: str) -> Optional[BatchTask]:
return _TASKS.get(task_id)
def delete_task(task_id: str) -> None:
_TASKS.pop(task_id, None)
async def expire_task(task_id: str, delay: int = 300) -> None:
await asyncio.sleep(delay)
delete_task(task_id)
+21 -3
View File
@@ -5,7 +5,7 @@
"""
import asyncio
from typing import Any, Awaitable, Callable, Dict, List, TypeVar
from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar
from app.core.logger import logger
@@ -18,6 +18,8 @@ async def run_in_batches(
*,
max_concurrent: int = 10,
batch_size: int = 50,
on_item: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None,
should_cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Dict[str, Any]]:
"""
分批并发执行,单项失败不影响整体
@@ -46,18 +48,34 @@ async def run_in_batches(
sem = asyncio.Semaphore(max_concurrent)
async def _one(item: str) -> tuple[str, dict]:
if should_cancel and should_cancel():
return item, {"ok": False, "error": "cancelled", "cancelled": True}
async with sem:
try:
data = await worker(item)
return item, {"ok": True, "data": data}
result = {"ok": True, "data": data}
if on_item:
try:
await on_item(item, result)
except Exception:
pass
return item, result
except Exception as e:
logger.warning(f"Batch item failed: {item[:16]}... - {e}")
return item, {"ok": False, "error": str(e)}
result = {"ok": False, "error": str(e)}
if on_item:
try:
await on_item(item, result)
except Exception:
pass
return item, result
results: Dict[str, dict] = {}
# 分批执行,避免一次性创建所有 task
for i in range(0, len(items), batch_size):
if should_cancel and should_cancel():
break
chunk = items[i : i + batch_size]
pairs = await asyncio.gather(*(_one(x) for x in chunk))
results.update(dict(pairs))
+7 -6
View File
@@ -9,15 +9,15 @@
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css" rel="stylesheet">
<link href="/static/cache/cache.css?v=3" rel="stylesheet">
<link href="/static/common/common.css?v=2" rel="stylesheet">
<link href="/static/cache/cache.css?v=4" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
<div id="app-header"></div>
<main class="space-y-6 flex-1 container mx-auto max-w-4xl px-6 py-8 fade-in">
<main class="space-y-6 flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
@@ -141,7 +141,7 @@
</thead>
<tbody id="account-table-body"></tbody>
</table>
<div id="account-empty" class="hidden text-center py-12 text-[var(--accents-4)]">暂无可用账号</div>
<div id="account-empty" class="hidden table-empty">暂无可用账号</div>
</div>
</div>
@@ -197,10 +197,11 @@
</dialog>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/header.js?v=1"></script>
<script src="/static/common/batch-sse.js?v=1"></script>
<script src="/static/common/header.js?v=2"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/cache/cache.js?v=3"></script>
<script src="/static/cache/cache.js?v=8"></script>
<script src="/static/common/draggable.js"></script>
</body>
+424 -307
View File
@@ -9,6 +9,7 @@ const selectedLocal = {
video: new Set()
};
const ui = {};
const byId = (id) => document.getElementById(id);
const loadFailed = new Map();
const deleteFailed = new Map();
let currentBatchAction = null;
@@ -18,6 +19,69 @@ const cacheListState = {
image: { loaded: false, visible: false, items: [] },
video: { loaded: false, visible: false, items: [] }
};
const UI_MAP = {
imgCount: 'img-count',
imgSize: 'img-size',
videoCount: 'video-count',
videoSize: 'video-size',
onlineCount: 'online-count',
onlineStatus: 'online-status',
onlineLastClear: 'online-last-clear',
accountTableBody: 'account-table-body',
accountEmpty: 'account-empty',
selectAll: 'select-all',
localImageSelectAll: 'local-image-select-all',
localVideoSelectAll: 'local-video-select-all',
selectedCount: 'selected-count',
batchActions: 'batch-actions',
loadBtn: 'btn-load-stats',
deleteBtn: 'btn-delete-assets',
localCacheLists: 'local-cache-lists',
localImageList: 'local-image-list',
localVideoList: 'local-video-list',
localImageBody: 'local-image-body',
localVideoBody: 'local-video-body',
onlineAssetsTable: 'online-assets-table',
batchProgress: 'batch-progress',
batchProgressText: 'batch-progress-text',
pauseActionBtn: 'btn-pause-action',
stopActionBtn: 'btn-stop-action',
failureDetailsBtn: 'btn-failure-details',
confirmDialog: 'confirm-dialog',
confirmMessage: 'confirm-message',
confirmOk: 'confirm-ok',
confirmCancel: 'confirm-cancel',
failureDialog: 'failure-dialog',
failureList: 'failure-list',
failureClose: 'failure-close',
failureRetry: 'failure-retry'
};
function setText(el, text) {
if (el) el.textContent = text;
}
function resolveOnlineStatus(status) {
if (status === 'ok') {
return { text: '连接正常', className: 'text-xs text-green-600 mt-1' };
}
if (status === 'no_token') {
return { text: '无可用 Token', className: 'text-xs text-orange-500 mt-1' };
}
if (status === 'not_loaded') {
return { text: '未加载', className: 'text-xs text-[var(--accents-4)] mt-1' };
}
return { text: '无法连接', className: 'text-xs text-red-500 mt-1' };
}
function createIconButton(title, svg, onClick) {
const btn = document.createElement('button');
btn.className = 'cache-icon-button';
btn.title = title;
btn.innerHTML = svg;
btn.addEventListener('click', onClick);
return btn;
}
async function init() {
apiKey = await ensureApiKey();
@@ -42,42 +106,10 @@ function setupCacheCards() {
}
function cacheUI() {
ui.imgCount = document.getElementById('img-count');
ui.imgSize = document.getElementById('img-size');
ui.videoCount = document.getElementById('video-count');
ui.videoSize = document.getElementById('video-size');
ui.onlineCount = document.getElementById('online-count');
ui.onlineStatus = document.getElementById('online-status');
ui.onlineLastClear = document.getElementById('online-last-clear');
ui.accountTableBody = document.getElementById('account-table-body');
ui.accountEmpty = document.getElementById('account-empty');
ui.selectAll = document.getElementById('select-all');
ui.localImageSelectAll = document.getElementById('local-image-select-all');
ui.localVideoSelectAll = document.getElementById('local-video-select-all');
ui.selectedCount = document.getElementById('selected-count');
ui.batchActions = document.getElementById('batch-actions');
ui.loadBtn = document.getElementById('btn-load-stats');
ui.deleteBtn = document.getElementById('btn-delete-assets');
ui.localCacheLists = document.getElementById('local-cache-lists');
ui.localImageList = document.getElementById('local-image-list');
ui.localVideoList = document.getElementById('local-video-list');
ui.localImageBody = document.getElementById('local-image-body');
ui.localVideoBody = document.getElementById('local-video-body');
Object.entries(UI_MAP).forEach(([key, id]) => {
ui[key] = byId(id);
});
ui.cacheCards = document.querySelectorAll('.cache-card');
ui.onlineAssetsTable = document.getElementById('online-assets-table');
ui.batchProgress = document.getElementById('batch-progress');
ui.batchProgressText = document.getElementById('batch-progress-text');
ui.pauseActionBtn = document.getElementById('btn-pause-action');
ui.stopActionBtn = document.getElementById('btn-stop-action');
ui.failureDetailsBtn = document.getElementById('btn-failure-details');
ui.confirmDialog = document.getElementById('confirm-dialog');
ui.confirmMessage = document.getElementById('confirm-message');
ui.confirmOk = document.getElementById('confirm-ok');
ui.confirmCancel = document.getElementById('confirm-cancel');
ui.failureDialog = document.getElementById('failure-dialog');
ui.failureList = document.getElementById('failure-list');
ui.failureClose = document.getElementById('failure-close');
ui.failureRetry = document.getElementById('failure-retry');
}
function ensureUI() {
@@ -180,7 +212,8 @@ let isBatchDeleting = false;
let isDeletePaused = false;
let deleteTotal = 0;
let deleteProcessed = 0;
const BATCH_SIZE = 10;
let currentBatchTaskId = null;
let batchEventSource = null;
async function loadStats(options = {}) {
try {
@@ -210,70 +243,7 @@ async function loadStats(options = {}) {
return;
}
const data = await res.json();
if (!merge) {
accountStates.clear();
}
if (ui.imgCount) ui.imgCount.textContent = data.local_image.count;
if (ui.imgSize) ui.imgSize.textContent = `${data.local_image.size_mb} MB`;
if (ui.videoCount) ui.videoCount.textContent = data.local_video.count;
if (ui.videoSize) ui.videoSize.textContent = `${data.local_video.size_mb} MB`;
if (ui.onlineCount) ui.onlineCount.textContent = data.online.count;
const statusEl = ui.onlineStatus;
const lastClearEl = ui.onlineLastClear;
if (data.online.status === 'ok') {
statusEl.textContent = '连接正常';
statusEl.className = 'text-xs text-green-600 mt-1';
} else if (data.online.status === 'no_token') {
statusEl.textContent = '无可用 Token';
statusEl.className = 'text-xs text-orange-500 mt-1';
} else if (data.online.status === 'not_loaded') {
statusEl.textContent = '未加载';
statusEl.className = 'text-xs text-[var(--accents-4)] mt-1';
} else {
statusEl.textContent = '无法连接';
statusEl.className = 'text-xs text-red-500 mt-1';
}
// Update master accounts list
updateAccountSelect(data.online_accounts || []);
// Update dynamic states
const details = Array.isArray(data.online_details) ? data.online_details : [];
details.forEach(detail => {
accountStates.set(detail.token, {
count: detail.count,
status: detail.status,
last_asset_clear_at: detail.last_asset_clear_at
});
});
if (data.online?.token) {
accountStates.set(data.online.token, {
count: data.online.count,
status: data.online.status,
last_asset_clear_at: data.online.last_asset_clear_at
});
}
if (data.online_scope === 'all') {
currentScope = 'all';
currentToken = '';
} else if (data.online_scope === 'selected') {
currentScope = 'selected';
} else if (data.online.token) {
currentScope = 'single';
currentToken = data.online.token;
} else {
currentScope = 'none';
}
if (lastClearEl) {
const timeText = formatTime(data.online.last_asset_clear_at);
lastClearEl.textContent = timeText ? `上次清空:${timeText}` : '';
}
renderAccountTable(data);
applyStatsData(data, merge);
return data;
} catch (e) {
if (!silent) showToast('加载统计失败', 'error');
@@ -281,6 +251,59 @@ async function loadStats(options = {}) {
}
}
function applyStatsData(data, merge = false) {
if (!merge) {
accountStates.clear();
}
setText(ui.imgCount, data.local_image.count);
setText(ui.imgSize, `${data.local_image.size_mb} MB`);
setText(ui.videoCount, data.local_video.count);
setText(ui.videoSize, `${data.local_video.size_mb} MB`);
setText(ui.onlineCount, data.online.count);
const online = data.online || {};
const status = resolveOnlineStatus(online.status);
setOnlineStatus(status.text, status.className);
// Update master accounts list
updateAccountSelect(data.online_accounts || []);
// Update dynamic states
const details = Array.isArray(data.online_details) ? data.online_details : [];
details.forEach(detail => {
accountStates.set(detail.token, {
count: detail.count,
status: detail.status,
last_asset_clear_at: detail.last_asset_clear_at
});
});
if (online?.token) {
accountStates.set(online.token, {
count: online.count,
status: online.status,
last_asset_clear_at: online.last_asset_clear_at
});
}
if (data.online_scope === 'all') {
currentScope = 'all';
currentToken = '';
} else if (data.online_scope === 'selected') {
currentScope = 'selected';
} else if (online.token) {
currentScope = 'single';
currentToken = online.token;
} else {
currentScope = 'none';
}
const timeText = formatTime(online.last_asset_clear_at);
setText(ui.onlineLastClear, timeText ? `上次清空:${timeText}` : '');
renderAccountTable(data);
}
function updateAccountSelect(accounts) {
accountMap.clear();
accounts.forEach(account => {
@@ -341,60 +364,78 @@ function renderAccountTable(data) {
}
if (rows.length === 0) {
tbody.innerHTML = '';
tbody.replaceChildren();
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
tbody.innerHTML = rows.map(row => {
let statusClass = 'badge-gray';
let statusText = '未加载';
const selected = selectedTokens;
const fragment = document.createDocumentFragment();
rows.forEach(row => {
const tr = document.createElement('tr');
const isSelected = selected.has(row.token);
if (isSelected) tr.classList.add('row-selected');
if (row.status === 'ok') {
statusClass = 'badge-green';
statusText = '正常';
} else if (row.status === 'not_loaded') {
statusClass = 'badge-gray';
statusText = '未加载';
} else {
statusClass = 'badge-red';
statusText = '异常';
}
const tdCheck = document.createElement('td');
tdCheck.className = 'text-center';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'checkbox';
checkbox.checked = isSelected;
checkbox.setAttribute('data-token', row.token);
checkbox.addEventListener('change', () => toggleSelect(row.token, checkbox));
tdCheck.appendChild(checkbox);
const lastClear = formatTime(row.last_asset_clear_at) || '-';
// Use token as identifier, but we might want index for simpler toggling if we switch to array-based state like token.js
// For now, keep Set-based logic but update UI
const checked = selectedTokens.has(row.token) ? 'checked' : '';
const tdToken = document.createElement('td');
tdToken.className = 'text-left';
const tokenWrap = document.createElement('div');
tokenWrap.className = 'flex items-center gap-2';
const tokenText = document.createElement('span');
tokenText.className = 'font-mono text-xs text-gray-500';
tokenText.title = row.token;
tokenText.textContent = row.token_masked || row.token;
tokenWrap.appendChild(tokenText);
tdToken.appendChild(tokenWrap);
// Shorten token for display if not already masked, though backend gives masked
// We'll use the masked version from backend
const tdPool = document.createElement('td');
tdPool.className = 'text-center';
const poolBadge = document.createElement('span');
poolBadge.className = 'badge badge-gray';
poolBadge.textContent = row.pool || '-';
tdPool.appendChild(poolBadge);
const rowClass = selectedTokens.has(row.token) ? 'row-selected' : '';
const countText = row.count === '-' ? '未加载' : row.count;
return `
<tr class="${rowClass}">
<td class="text-center">
<input type="checkbox" class="checkbox" data-token="${row.token}" ${checked} onchange="toggleSelect('${row.token}', this)">
</td>
<td class="text-left">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-500" title="${row.token}">${row.token_masked}</span>
</div>
</td>
<td class="text-center"><span class="badge badge-gray">${row.pool || '-'}</span></td>
<td class="text-center"><span class="badge badge-gray">${countText}</span></td>
<td class="text-left text-xs text-gray-500">${lastClear}</td>
<td class="text-center">
<div class="flex items-center justify-center gap-2">
<button class="cache-icon-button" onclick="clearOnlineCache('${row.token}')" title="清空">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</td>
</tr>
`;
}).join('');
const tdCount = document.createElement('td');
tdCount.className = 'text-center';
const countBadge = document.createElement('span');
countBadge.className = 'badge badge-gray';
countBadge.textContent = row.count === '-' ? '未加载' : row.count;
tdCount.appendChild(countBadge);
const tdLast = document.createElement('td');
tdLast.className = 'text-left text-xs text-gray-500';
tdLast.textContent = formatTime(row.last_asset_clear_at) || '-';
const tdActions = document.createElement('td');
tdActions.className = 'text-center';
const actionsWrap = document.createElement('div');
actionsWrap.className = 'flex items-center justify-center gap-2';
actionsWrap.appendChild(createIconButton(
'清空',
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
() => clearOnlineCache(row.token)
));
tdActions.appendChild(actionsWrap);
tr.appendChild(tdCheck);
tr.appendChild(tdToken);
tr.appendChild(tdPool);
tr.appendChild(tdCount);
tr.appendChild(tdLast);
tr.appendChild(tdActions);
fragment.appendChild(tr);
});
tbody.replaceChildren(fragment);
syncSelectAllState();
updateSelectedCount();
updateBatchActionsVisibility();
@@ -417,6 +458,18 @@ async function clearCache(type) {
const data = await res.json();
if (data.status === 'success') {
showToast(`清理成功,释放 ${data.result.size_mb} MB`, 'success');
const state = cacheListState[type];
if (state) {
state.items = [];
state.loaded = true;
}
if (selectedLocal[type]) selectedLocal[type].clear();
if (state && state.visible) {
renderLocalCacheList(type, []);
} else {
syncLocalSelectAllState(type);
updateSelectedCount();
}
loadStats();
} else {
showToast('清理失败', 'error');
@@ -611,15 +664,19 @@ function updateBatchProgress() {
container.classList.remove('hidden');
if (ui.pauseActionBtn) {
const paused = isLoading ? isLoadPaused : isDeletePaused;
ui.pauseActionBtn.textContent = paused ? '继续' : '暂停';
ui.pauseActionBtn.classList.remove('hidden');
ui.pauseActionBtn.classList.add('hidden');
}
if (ui.stopActionBtn) {
ui.stopActionBtn.classList.remove('hidden');
}
}
function refreshBatchUI() {
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
}
function setOnlineStatus(text, className) {
const statusEl = ui.onlineStatus;
if (!statusEl) return;
@@ -640,8 +697,6 @@ function updateToolbarForSection() {
}
function updateOnlineCountFromTokens(tokens) {
const el = ui.onlineCount;
if (!el) return;
let total = 0;
tokens.forEach(token => {
const state = accountStates.get(token);
@@ -649,7 +704,7 @@ function updateOnlineCountFromTokens(tokens) {
total += state.count;
}
});
el.textContent = String(total);
setText(ui.onlineCount, String(total));
}
function formatSize(bytes) {
@@ -741,48 +796,79 @@ function renderLocalCacheList(type, items) {
const body = type === 'image' ? ui.localImageBody : ui.localVideoBody;
if (!body) return;
if (!items || items.length === 0) {
body.innerHTML = `<tr><td colspan="5">暂无文件</td></tr>`;
body.innerHTML = `<tr><td colspan="5" class="table-empty">暂无文件</td></tr>`;
syncLocalSelectAllState(type);
return;
}
const selected = selectedLocal[type];
body.innerHTML = items.map(item => {
const timeText = formatTime(item.mtime_ms);
const preview = item.preview_url ? `<img src="${item.preview_url}" alt="" class="cache-preview">` : '';
const checked = selected.has(item.name) ? 'checked' : '';
const rowClass = selected.has(item.name) ? 'row-selected' : '';
return `
<tr class="${rowClass}">
<td class="text-center">
<input type="checkbox" class="checkbox" data-name="${item.name}" ${checked} onchange="toggleLocalSelect('${type}', '${item.name}', this)">
</td>
<td class="text-left">
<div class="flex items-center gap-2">
${preview}
<span class="font-mono text-xs text-gray-500">${item.name}</span>
</div>
</td>
<td class="text-left">${formatSize(item.size_bytes)}</td>
<td class="text-left text-xs text-gray-500">${timeText}</td>
<td class="text-center">
<div class="cache-list-actions">
<button class="cache-icon-button" onclick="viewLocalFile('${type}', '${item.name}')" title="查看">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button class="cache-icon-button" onclick="deleteLocalFile('${type}', '${item.name}')" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
const fragment = document.createDocumentFragment();
items.forEach(item => {
const tr = document.createElement('tr');
const isSelected = selected.has(item.name);
if (isSelected) tr.classList.add('row-selected');
const tdCheck = document.createElement('td');
tdCheck.className = 'text-center';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'checkbox';
checkbox.checked = isSelected;
checkbox.setAttribute('data-name', item.name);
checkbox.onchange = () => toggleLocalSelect(type, item.name, checkbox);
tdCheck.appendChild(checkbox);
const tdName = document.createElement('td');
tdName.className = 'text-left';
const nameWrap = document.createElement('div');
nameWrap.className = 'flex items-center gap-2';
if (item.preview_url) {
const img = document.createElement('img');
img.src = item.preview_url;
img.alt = '';
img.className = 'cache-preview';
nameWrap.appendChild(img);
}
const nameText = document.createElement('span');
nameText.className = 'font-mono text-xs text-gray-500';
nameText.textContent = item.name;
nameWrap.appendChild(nameText);
tdName.appendChild(nameWrap);
const tdSize = document.createElement('td');
tdSize.className = 'text-left';
tdSize.textContent = formatSize(item.size_bytes);
const tdTime = document.createElement('td');
tdTime.className = 'text-left text-xs text-gray-500';
tdTime.textContent = formatTime(item.mtime_ms);
const tdActions = document.createElement('td');
tdActions.className = 'text-center';
tdActions.innerHTML = `
<div class="cache-list-actions">
<button class="cache-icon-button" onclick="viewLocalFile('${type}', '${item.name}')" title="查看">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button class="cache-icon-button" onclick="deleteLocalFile('${type}', '${item.name}')" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`;
}).join('');
tr.appendChild(tdCheck);
tr.appendChild(tdName);
tr.appendChild(tdSize);
tr.appendChild(tdTime);
tr.appendChild(tdActions);
fragment.appendChild(tr);
});
body.replaceChildren(fragment);
syncLocalSelectAllState(type);
updateSelectedCount();
}
@@ -894,55 +980,47 @@ function handleDeleteClick() {
}
}
function stopBatchLoad() {
function stopBatchLoad(options = {}) {
if (!isBatchLoading) return;
isBatchLoading = false;
isLoadPaused = false;
currentBatchAction = null;
batchQueue = [];
BatchSSE.close(batchEventSource);
batchEventSource = null;
currentBatchTaskId = null;
setOnlineStatus('已终止', 'text-xs text-[var(--accents-4)] mt-1');
updateLoadButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('已终止剩余加载请求', 'info');
refreshBatchUI();
if (!options.silent) showToast('已终止剩余加载请求', 'info');
}
function stopBatchDelete() {
function stopBatchDelete(options = {}) {
if (!isBatchDeleting) return;
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
batchQueue = [];
BatchSSE.close(batchEventSource);
batchEventSource = null;
currentBatchTaskId = null;
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('已终止剩余清理请求', 'info');
refreshBatchUI();
if (!options.silent) showToast('已终止剩余清理请求', 'info');
}
function togglePause() {
if (isBatchLoading) {
isLoadPaused = !isLoadPaused;
if (isLoadPaused) {
setOnlineStatus('已暂停', 'text-xs text-[var(--accents-4)] mt-1');
} else {
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
processBatchQueue();
}
} else if (isBatchDeleting) {
isDeletePaused = !isDeletePaused;
if (!isDeletePaused) {
processDeleteQueue();
}
if (isBatchLoading || isBatchDeleting) {
showToast('当前批量任务不支持暂停', 'info');
}
updateBatchProgress();
}
function stopActiveBatch() {
if (isBatchLoading) {
BatchSSE.cancel(currentBatchTaskId, apiKey);
stopBatchLoad();
} else if (isBatchDeleting) {
BatchSSE.cancel(currentBatchTaskId, apiKey);
stopBatchDelete();
}
}
@@ -996,7 +1074,7 @@ function retryFailed() {
}
}
function startBatchLoad(tokens) {
async function startBatchLoad(tokens) {
if (isBatchLoading) {
showToast('正在加载中,请稍候', 'info');
return;
@@ -1020,52 +1098,79 @@ function startBatchLoad(tokens) {
updateOnlineCountFromTokens(batchTokens);
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
updateLoadButton();
setActionButtonsState();
if (accountMap.size > 0) {
renderAccountTable({ online_accounts: Array.from(accountMap.values()), online_details: [], online: {} });
}
updateBatchActionsVisibility();
updateBatchProgress();
refreshBatchUI();
processBatchQueue();
}
try {
const res = await fetch('/api/v1/admin/cache/online/load/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens })
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
throw new Error(data.detail || '请求失败');
}
async function processBatchQueue() {
if (!isBatchLoading || isLoadPaused) return;
if (batchQueue.length === 0) {
finishBatchLoad();
return;
}
const chunk = batchQueue.splice(0, BATCH_SIZE);
const data = await loadStats({ tokens: chunk, merge: true, silent: true });
if (!data) {
chunk.forEach(token => loadFailed.set(token, '请求失败'));
} else {
const details = Array.isArray(data.online_details) ? data.online_details : [];
const detailMap = new Map(details.map(item => [item.token, item]));
chunk.forEach(token => {
const detail = detailMap.get(token);
if (!detail) {
loadFailed.set(token, '返回为空');
return;
}
if (detail.status !== 'ok') {
loadFailed.set(token, detail.status);
} else {
loadFailed.delete(token);
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') batchTotal = msg.total;
if (typeof msg.processed === 'number') batchProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') batchTotal = msg.total;
batchProcessed = batchTotal;
updateBatchProgress();
const result = msg.result;
if (result) {
applyStatsData(result, true);
const details = Array.isArray(result.online_details) ? result.online_details : [];
loadFailed.clear();
details.forEach(detail => {
if (detail.status !== 'ok') loadFailed.set(detail.token, detail.status);
});
}
finishBatchLoad();
if (msg.warning) {
showToast(`加载完成\n⚠️ ${msg.warning}`, 'warning');
}
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'cancelled') {
stopBatchLoad({ silent: true });
showToast('已终止加载', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'error') {
stopBatchLoad({ silent: true });
showToast('加载失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
},
onError: () => {
stopBatchLoad({ silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
});
} catch (e) {
stopBatchLoad({ silent: true });
showToast(e.message || '请求失败', 'error');
}
batchProcessed += chunk.length;
updateOnlineCountFromTokens(batchTokens);
updateLoadButton();
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
updateBatchProgress();
setTimeout(() => {
processBatchQueue();
}, 300);
}
function finishBatchLoad() {
@@ -1085,9 +1190,7 @@ function finishBatchLoad() {
setOnlineStatus('连接正常', 'text-xs text-green-600 mt-1');
}
updateLoadButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
refreshBatchUI();
}
async function loadSelectedAccounts() {
@@ -1125,7 +1228,7 @@ async function clearSelectedAccounts() {
startBatchDelete(Array.from(selectedTokens));
}
function startBatchDelete(tokens) {
async function startBatchDelete(tokens) {
if (!tokens || tokens.length === 0) return;
isBatchDeleting = true;
isDeletePaused = false;
@@ -1137,55 +1240,9 @@ function startBatchDelete(tokens) {
batchQueue = tokens.slice();
showToast('正在批量清理在线资产,请稍候...', 'info');
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
processDeleteQueue();
}
async function processDeleteQueue() {
if (!isBatchDeleting || isDeletePaused) return;
if (batchQueue.length === 0) {
finishBatchDelete();
return;
}
const chunk = batchQueue.splice(0, BATCH_SIZE);
const results = await clearOnlineCacheBatch(chunk);
if (results && results.status === 'success' && results.results) {
Object.entries(results.results).forEach(([token, result]) => {
if (result.status !== 'success') {
deleteFailed.set(token, result.error || '清理失败');
} else {
deleteFailed.delete(token);
}
});
} else {
chunk.forEach(token => deleteFailed.set(token, '请求失败'));
}
deleteProcessed += chunk.length;
updateDeleteButton();
updateBatchProgress();
setTimeout(() => {
processDeleteQueue();
}, 300);
}
function finishBatchDelete() {
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('批量清理完成', 'success');
loadStats();
}
async function clearOnlineCacheBatch(tokens = []) {
if (!tokens || tokens.length === 0) return;
refreshBatchUI();
try {
const res = await fetch('/api/v1/admin/cache/online/clear', {
const res = await fetch('/api/v1/admin/cache/online/clear/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1195,15 +1252,75 @@ async function clearOnlineCacheBatch(tokens = []) {
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
showToast('批量清理失败', 'error');
throw new Error(data.detail || '请求失败');
}
return data;
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') deleteTotal = msg.total;
if (typeof msg.processed === 'number') deleteProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') deleteTotal = msg.total;
deleteProcessed = deleteTotal;
updateBatchProgress();
const result = msg.result;
deleteFailed.clear();
if (result && result.results) {
Object.entries(result.results).forEach(([token, res]) => {
if (res.status !== 'success') {
deleteFailed.set(token, res.error || '清理失败');
}
});
}
finishBatchDelete();
if (msg.warning) {
showToast(`清理完成\n⚠️ ${msg.warning}`, 'warning');
}
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'cancelled') {
stopBatchDelete({ silent: true });
showToast('已终止清理', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'error') {
stopBatchDelete({ silent: true });
showToast('清理失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
},
onError: () => {
stopBatchDelete({ silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
});
} catch (e) {
showToast('请求超时或失败', 'error');
return null;
stopBatchDelete({ silent: true });
showToast(e.message || '请求失败', 'error');
}
}
function finishBatchDelete() {
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
updateDeleteButton();
refreshBatchUI();
showToast('批量清理完成', 'success');
loadStats();
}
async function clearOnlineCache(targetToken = '', skipConfirm = false) {
const tokenToClear = targetToken || (currentScope === 'all' ? '' : currentToken);
if (!tokenToClear) {
+46
View File
@@ -0,0 +1,46 @@
(function (global) {
function openBatchStream(taskId, apiKey, handlers = {}) {
if (!taskId) return null;
const url = `/api/v1/admin/batch/${taskId}/stream?api_key=${encodeURIComponent(apiKey || '')}`;
const es = new EventSource(url);
es.onmessage = (e) => {
if (!e.data) return;
let msg;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
if (handlers.onMessage) handlers.onMessage(msg);
};
es.onerror = () => {
if (handlers.onError) handlers.onError();
};
return es;
}
function closeBatchStream(es) {
if (es) es.close();
}
async function cancelBatchTask(taskId, apiKey) {
if (!taskId) return;
try {
await fetch(`/api/v1/admin/batch/${taskId}/cancel`, {
method: 'POST',
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined
});
} catch {
// ignore
}
}
global.BatchSSE = {
open: openBatchStream,
close: closeBatchStream,
cancel: cancelBatchTask
};
})(window);
+12
View File
@@ -30,6 +30,18 @@ body {
font-family: 'Geist Mono', monospace;
}
.table-empty {
color: var(--accents-4);
font-size: 12px;
font-weight: 400;
padding: 48px 0;
text-align: center;
}
td.table-empty {
height: auto;
}
.fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
+1 -1
View File
@@ -1,5 +1,5 @@
<footer class="app-footer">
<div class="max-w-4xl mx-auto px-6">
<div class="max-w-5xl mx-auto px-6">
<a href="https://github.com/chenyme/grok2api" target="_blank" rel="noopener">Grok2API</a>
<span> Created By </span>
<a href="https://github.com/chenyme" target="_blank" rel="noopener">@Chenyme</a>
+1 -1
View File
@@ -1,5 +1,5 @@
<nav class="bg-[var(--bg)]/80 backdrop-blur-md fixed top-0 left-0 right-0 z-10">
<div class="max-w-4xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<a href="https://github.com/chenyme/grok2api" target="_blank" rel="noopener"
+1 -1
View File
@@ -2,7 +2,7 @@ async function loadAdminHeader() {
const container = document.getElementById('app-header');
if (!container) return;
try {
const res = await fetch('/static/common/header.html?v=1');
const res = await fetch('/static/common/header.html?v=2');
if (!res.ok) return;
container.innerHTML = await res.text();
const path = window.location.pathname;
+4 -4
View File
@@ -9,7 +9,7 @@
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css" rel="stylesheet">
<link href="/static/common/common.css?v=2" rel="stylesheet">
<link href="/static/config/config.css?v=3" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
</head>
@@ -18,7 +18,7 @@
<div id="app-header"></div>
<main class="space-y-6 flex-1 container mx-auto max-w-4xl px-6 py-8 fade-in">
<main class="space-y-6 flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
@@ -47,10 +47,10 @@
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/header.js?v=1"></script>
<script src="/static/common/header.js?v=2"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/config/config.js?v=6"></script>
<script src="/static/config/config.js?v=7"></script>
</body>
</html>
+142 -116
View File
@@ -1,5 +1,6 @@
let apiKey = '';
let currentConfig = {};
const byId = (id) => document.getElementById(id);
const NUMERIC_FIELDS = new Set([
'timeout',
'max_retry',
@@ -11,10 +12,13 @@ const NUMERIC_FIELDS = new Set([
'media_max_concurrent',
'usage_max_concurrent',
'assets_delete_batch_size',
'admin_assets_batch_size',
'assets_batch_size',
'assets_max_tokens',
'usage_batch_size',
'usage_max_tokens',
'reload_interval_sec',
'nsfw_max_concurrent',
'admin_nsfw_batch_size',
'nsfw_batch_size',
'nsfw_max_tokens'
]);
@@ -56,17 +60,22 @@ const LOCALE_MAP = {
},
"performance": {
"label": "并发性能",
"assets_max_concurrent": { title: "资产并发上限", desc: "资源上传/下载/列表的并发上限。推荐 25。" },
"media_max_concurrent": { title: "媒体并发上限", desc: "视频/媒体生成请求的并发上限。推荐 50。" },
"usage_max_concurrent": { title: "用量并发上限", desc: "用量查询请求的并发上限。推荐 25。" },
"assets_delete_batch_size": { title: "资产清理批量", desc: "在线资产删除单批并发数量。推荐 10。" },
"admin_assets_batch_size": { title: "管理端批量", desc: "管理端在线资产统计/清理批量并发数量。推荐 10。" },
"nsfw_max_concurrent": { title: "NSFW 并发上限", desc: "批量开启 NSFW 模式的并发请求数。推荐 10。" },
"admin_nsfw_batch_size": { title: "NSFW 批量大小", desc: "管理端批量开启 NSFW 的单批处理数量。推荐 50。" },
"nsfw_max_tokens": { title: "NSFW 最大数量", desc: "单次批量开启 NSFW 的 Token 数量上限,防止误操作。推荐 1000。" }
"media_max_concurrent": { title: "Media 并发上限", desc: "视频/媒体生成请求的并发上限。推荐 50。" },
"nsfw_max_concurrent": { title: "NSFW 开启并发上限", desc: "批量开启 NSFW 模式时的并发请求上限。推荐 10。" },
"nsfw_batch_size": { title: "NSFW 开启批量大小", desc: "批量开启 NSFW 模式的单批处理数量。推荐 50。" },
"nsfw_max_tokens": { title: "NSFW 开启最大数量", desc: "单次批量开启 NSFW 的 Token 数量上限,防止误操作。推荐 1000。" },
"usage_max_concurrent": { title: "Token 刷新并发上限", desc: "批量刷新 Token 用量时的并发请求上限。推荐 25。" },
"usage_batch_size": { title: "Token 刷新批次大小", desc: "批量刷新 Token 用量的单批处理数量。推荐 50。" },
"usage_max_tokens": { title: "Token 刷新最大数量", desc: "单次批量刷新 Token 用量时的处理数量上限。推荐 1000。" },
"assets_max_concurrent": { title: "Assets 处理并发上限", desc: "批量查找/删除资产时的并发请求上限。推荐 25。" },
"assets_batch_size": { title: "Assets 处理批次大小", desc: "批量查找/删除资产时的单批处理数量。推荐 10。" },
"assets_max_tokens": { title: "Assets 处理最大数量", desc: "单次批量查找/删除资产时的处理数量上限。推荐 1000。" },
"assets_delete_batch_size": { title: "Assets 单账号删除批量大小", desc: "单账号批量删除资产时的单批并发数量。推荐 10。" }
}
};
const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index]));
function getText(section, key) {
if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) {
return LOCALE_MAP[section][key];
@@ -81,6 +90,101 @@ function getSectionLabel(section) {
return (LOCALE_MAP[section] && LOCALE_MAP[section].label) || `${section} 设置`;
}
function sortByOrder(keys, orderMap) {
if (!orderMap) return keys;
return keys.sort((a, b) => {
const ia = orderMap.get(a);
const ib = orderMap.get(b);
if (ia !== undefined && ib !== undefined) return ia - ib;
if (ia !== undefined) return -1;
if (ib !== undefined) return 1;
return 0;
});
}
function setInputMeta(input, section, key) {
input.dataset.section = section;
input.dataset.key = key;
}
function createOption(value, text, selectedValue) {
const option = document.createElement('option');
option.value = value;
option.text = text;
if (selectedValue !== undefined && selectedValue === value) option.selected = true;
return option;
}
function buildBooleanInput(section, key, val) {
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = val;
input.className = 'sr-only peer';
setInputMeta(input, section, key);
const slider = document.createElement('div');
slider.className = "w-9 h-5 bg-[var(--accents-2)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-black";
label.appendChild(input);
label.appendChild(slider);
return { input, node: label };
}
function buildSelectInput(section, key, val, options) {
const input = document.createElement('select');
input.className = 'geist-input h-[34px]';
setInputMeta(input, section, key);
options.forEach(opt => {
input.appendChild(createOption(opt.val, opt.text, val));
});
return { input, node: input };
}
function buildJsonInput(section, key, val) {
const input = document.createElement('textarea');
input.className = 'geist-input font-mono text-xs';
input.rows = 4;
input.value = JSON.stringify(val, null, 2);
setInputMeta(input, section, key);
input.dataset.type = 'json';
return { input, node: input };
}
function buildTextInput(section, key, val) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'geist-input';
input.value = val;
setInputMeta(input, section, key);
return { input, node: input };
}
function buildSecretInput(section, key, val) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'geist-input flex-1 h-[34px]';
input.value = val;
setInputMeta(input, section, key);
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center gap-2';
const copyBtn = document.createElement('button');
copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
copyBtn.type = 'button';
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.onclick = () => copyToClipboard(input.value, copyBtn);
wrapper.appendChild(input);
wrapper.appendChild(copyBtn);
return { input, node: wrapper };
}
async function init() {
apiKey = await ensureApiKey();
if (apiKey === null) return;
@@ -104,23 +208,17 @@ async function loadData() {
}
function renderConfig(data) {
const container = document.getElementById('config-container');
container.innerHTML = '';
const container = byId('config-container');
if (!container) return;
container.replaceChildren();
const sections = Object.keys(data);
const sectionOrder = Object.keys(LOCALE_MAP);
sections.sort((a, b) => {
const ia = sectionOrder.indexOf(a);
const ib = sectionOrder.indexOf(b);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1; // Known sections first
if (ib !== -1) return 1;
return 0;
});
const fragment = document.createDocumentFragment();
const sections = sortByOrder(Object.keys(data), SECTION_ORDER);
sections.forEach(section => {
const items = data[section];
const localeSection = LOCALE_MAP[section];
const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null;
const card = document.createElement('div');
card.className = 'config-section';
@@ -132,18 +230,7 @@ function renderConfig(data) {
const grid = document.createElement('div');
grid.className = 'config-grid';
const keys = Object.keys(items);
if (LOCALE_MAP[section]) {
const order = Object.keys(LOCALE_MAP[section]);
keys.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1;
if (ib !== -1) return 1;
return 0;
});
}
const keys = sortByOrder(Object.keys(items), keyOrder);
keys.forEach(key => {
const val = items[key];
@@ -170,100 +257,35 @@ function renderConfig(data) {
inputWrapper.className = 'config-field-input';
// Input Logic
let input;
let built;
if (typeof val === 'boolean') {
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer';
input = document.createElement('input');
input.type = 'checkbox';
input.checked = val;
input.className = 'sr-only peer';
input.dataset.section = section;
input.dataset.key = key;
const slider = document.createElement('div');
slider.className = "w-9 h-5 bg-[var(--accents-2)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-black";
label.appendChild(input);
label.appendChild(slider);
inputWrapper.appendChild(label);
built = buildBooleanInput(section, key, val);
}
else if (key === 'image_format') {
input = document.createElement('select');
input.className = 'geist-input h-[34px]'; // Matches reduced padding inputs
input.dataset.section = section;
input.dataset.key = key;
const opts = [
built = buildSelectInput(section, key, val, [
{ val: 'url', text: 'URL' },
{ val: 'base64', text: 'Base64' }
];
opts.forEach(opt => {
const option = document.createElement('option');
option.value = opt.val;
option.text = opt.text;
if (val === opt.val) option.selected = true;
input.appendChild(option);
});
inputWrapper.appendChild(input);
]);
}
else if (key === 'video_format') {
input = document.createElement('select');
input.className = 'geist-input h-[34px]';
input.dataset.section = section;
input.dataset.key = key;
const option = document.createElement('option');
option.value = 'url';
option.text = 'URL';
option.selected = true;
input.appendChild(option);
inputWrapper.appendChild(input);
built = buildSelectInput(section, key, 'url', [
{ val: 'url', text: 'URL' }
]);
}
else if (Array.isArray(val) || typeof val === 'object') {
input = document.createElement('textarea');
input.className = 'geist-input font-mono text-xs';
input.rows = 4;
input.value = JSON.stringify(val, null, 2);
input.dataset.section = section;
input.dataset.key = key;
input.dataset.type = 'json';
inputWrapper.appendChild(input);
built = buildJsonInput(section, key, val);
}
else {
input = document.createElement('input');
input.type = 'text';
input.className = 'geist-input';
input.value = val;
input.dataset.section = section;
input.dataset.key = key;
if (key === 'app_key') input.type = 'text';
if (key === 'api_key' || key === 'app_key') {
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center gap-2';
input.className = 'geist-input flex-1 h-[34px]';
const copyBtn = document.createElement('button');
copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
copyBtn.type = 'button';
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.onclick = () => copyToClipboard(input.value, copyBtn);
wrapper.appendChild(input);
wrapper.appendChild(copyBtn);
inputWrapper.appendChild(wrapper);
built = buildSecretInput(section, key, val);
} else {
inputWrapper.appendChild(input);
built = buildTextInput(section, key, val);
}
}
if (built) {
inputWrapper.appendChild(built.node);
}
fieldCard.appendChild(inputWrapper);
grid.appendChild(fieldCard);
});
@@ -271,19 +293,23 @@ function renderConfig(data) {
card.appendChild(grid);
if (grid.children.length > 0) {
container.appendChild(card);
fragment.appendChild(card);
}
});
container.appendChild(fragment);
}
async function saveConfig() {
const btn = document.getElementById('save-btn');
const btn = byId('save-btn');
const originalText = btn.innerText;
btn.disabled = true;
btn.innerText = '保存中...';
try {
const newConfig = JSON.parse(JSON.stringify(currentConfig));
const newConfig = typeof structuredClone === 'function'
? structuredClone(currentConfig)
: JSON.parse(JSON.stringify(currentConfig));
const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section]');
inputs.forEach(input => {
+1 -1
View File
@@ -24,7 +24,7 @@
}
}
</script>
<link href="/static/common/common.css" rel="stylesheet">
<link href="/static/common/common.css?v=2" rel="stylesheet">
<link href="/static/login/login.css?v=1" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
</head>
+22 -17
View File
@@ -1,18 +1,25 @@
document.getElementById('api-key-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') login();
});
const apiKeyInput = document.getElementById('api-key-input');
if (apiKeyInput) {
apiKeyInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') login();
});
}
async function requestLogin(key) {
const res = await fetch('/api/v1/admin/login', {
method: 'POST',
headers: { 'Authorization': `Bearer ${key}` }
});
return res.ok;
}
async function login() {
const input = document.getElementById('api-key-input').value.trim();
const input = (apiKeyInput ? apiKeyInput.value : '').trim();
if (!input) return;
try {
const res = await fetch('/api/v1/admin/login', {
method: 'POST',
headers: { 'Authorization': `Bearer ${input}` }
});
if (res.ok) {
const ok = await requestLogin(input);
if (ok) {
await storeAppKey(input);
window.location.href = '/admin/token';
} else {
@@ -26,13 +33,11 @@ async function login() {
// Auto-redirect checks
(async () => {
const existingKey = await getStoredAppKey();
if (existingKey) {
fetch('/api/v1/admin/login', {
method: 'POST',
headers: { 'Authorization': `Bearer ${existingKey}` }
}).then(res => {
if (res.ok) window.location.href = '/admin/token';
});
if (!existingKey) return;
try {
const ok = await requestLogin(existingKey);
if (ok) window.location.href = '/admin/token';
} catch (e) {
return;
}
})();
+37 -10
View File
@@ -66,6 +66,14 @@
background: #f0f2f5;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 4px;
}
/* Elegant Badges */
.badge {
display: inline-flex;
@@ -284,29 +292,48 @@
padding: 0;
}
/* Tab Styles (Geist Design System) */
/* Tab Styles */
#status-tabs {
border: none;
background: #f6f6f6;
padding: 6px;
gap: 6px;
border-radius: 999px;
}
.tab-item {
position: relative;
padding: 12px 0;
margin-right: 20px;
font-size: 14px;
padding: 6px 12px;
margin-right: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--accents-5);
background: transparent;
border: none;
box-shadow: inset 0 -2px 0 0 transparent;
border-radius: 999px;
cursor: pointer;
transition: color 0.15s ease, box-shadow 0.15s ease;
transition: color 0.15s ease, background 0.15s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tab-item:hover {
color: var(--geist-foreground, #000);
background: rgba(0, 0, 0, 0.05);
}
.tab-item.active {
color: var(--geist-foreground, #000);
box-shadow: inset 0 -2px 0 0 var(--geist-foreground, #000);
background: #fff;
}
#status-tabs .badge {
height: 20px;
line-height: 20px;
font-size: 11px;
padding: 0 8px;
}
.tab-item:disabled {
@@ -325,7 +352,7 @@
.tab-sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 8px;
height: 16px;
background: #e5e5e5;
margin: 0 4px;
}
+25 -6
View File
@@ -9,7 +9,7 @@
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css" rel="stylesheet">
<link href="/static/common/common.css?v=2" rel="stylesheet">
<link href="/static/token/token.css?v=11" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
</head>
@@ -21,7 +21,7 @@
<div id="app-header"></div>
<!-- Main Content -->
<main class="space-y-6 flex-1 container mx-auto max-w-4xl px-6 py-8 fade-in">
<main class="space-y-6 flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
<div class="space-y-6">
<div class="flex justify-between items-center">
@@ -138,11 +138,29 @@
</tbody>
</table>
<div id="loading" class="text-center py-12 text-[var(--accents-4)]">加载中...</div>
<div id="empty-state" class="hidden text-center py-12 text-[var(--accents-4)]">
<div id="empty-state" class="hidden table-empty">
暂无 Token,请点击右上角导入或添加。
</div>
</div>
<!-- Pagination -->
<div class="pagination-bar">
<div id="pagination-info" class="text-xs text-[var(--accents-5)]">第 0 / 0 页 · 共 0 条</div>
<div class="flex items-center gap-2">
<button class="geist-button-outline text-xs px-3" onclick="selectVisibleAll()">全选本页</button>
<button class="geist-button-outline text-xs px-3" onclick="selectAllFiltered()">全选全部</button>
<button class="geist-button-outline text-xs px-3" onclick="clearAllSelection()">取消全选</button>
<button id="page-prev" class="geist-button-outline text-xs px-3" onclick="goPrevPage()">上一页</button>
<button id="page-next" class="geist-button-outline text-xs px-3" onclick="goNextPage()">下一页</button>
<select id="page-size" class="geist-input h-[30px]" onchange="changePageSize()">
<option value="20">20 / 页</option>
<option value="50" selected>50 / 页</option>
<option value="100">100 / 页</option>
<option value="200">200 / 页</option>
</select>
</div>
</div>
<!-- Batch Actions Bar (Floating Bottom) -->
</div>
@@ -168,7 +186,7 @@
刷新
</button>
<button id="btn-batch-nsfw" onclick="batchEnableNSFW()"
class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100 justify-center">
class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100 justify-center" disabled>
NSFW
</button>
<button id="btn-batch-delete" onclick="batchDelete()" class="geist-button-danger text-xs px-3">
@@ -275,10 +293,11 @@
<script src="/static/common/draggable.js"></script>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/header.js?v=1"></script>
<script src="/static/common/batch-sse.js?v=1"></script>
<script src="/static/common/header.js?v=2"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/token/token.js?v=11"></script>
<script src="/static/token/token.js?v=18"></script>
</body>
</html>
+437 -223
View File
@@ -8,7 +8,87 @@ let batchTotal = 0;
let batchProcessed = 0;
let currentBatchAction = null;
let currentFilter = 'all';
const BATCH_SIZE = 50;
let currentBatchTaskId = null;
let batchEventSource = null;
let currentPage = 1;
let pageSize = 50;
const byId = (id) => document.getElementById(id);
const qsa = (selector) => document.querySelectorAll(selector);
function setText(id, text) {
const el = byId(id);
if (el) el.innerText = text;
}
function openModal(id) {
const modal = byId(id);
if (!modal) return null;
modal.classList.remove('hidden');
requestAnimationFrame(() => {
modal.classList.add('is-open');
});
return modal;
}
function closeModal(id, onClose) {
const modal = byId(id);
if (!modal) return;
modal.classList.remove('is-open');
setTimeout(() => {
modal.classList.add('hidden');
if (onClose) onClose();
}, 200);
}
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function getSelectedTokens() {
return flatTokens.filter(t => t._selected);
}
function countSelected(tokens) {
let count = 0;
for (const t of tokens) {
if (t._selected) count++;
}
return count;
}
function setSelectedForTokens(tokens, selected) {
tokens.forEach(t => {
t._selected = selected;
});
}
function syncVisibleSelectionUI(selected) {
qsa('#token-table-body input[type="checkbox"]').forEach(input => {
input.checked = selected;
});
qsa('#token-table-body tr').forEach(row => {
row.classList.toggle('row-selected', selected);
});
}
function getPaginationData() {
const filteredTokens = getFilteredTokens();
const totalCount = filteredTokens.length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (currentPage > totalPages) currentPage = totalPages;
const startIndex = (currentPage - 1) * pageSize;
const visibleTokens = filteredTokens.slice(startIndex, startIndex + pageSize);
return { filteredTokens, totalCount, totalPages, visibleTokens };
}
async function init() {
apiKey = await ensureApiKey();
@@ -69,6 +149,8 @@ function updateStats(data) {
let activeTokens = 0;
let coolingTokens = 0;
let invalidTokens = 0;
let nsfwTokens = 0;
let noNsfwTokens = 0;
let chatQuota = 0;
let totalCalls = 0;
@@ -81,16 +163,16 @@ function updateStats(data) {
} else {
invalidTokens++;
}
if (t.tags && t.tags.includes('nsfw')) {
nsfwTokens++;
} else {
noNsfwTokens++;
}
totalCalls += Number(t.use_count || 0);
});
const imageQuota = Math.floor(chatQuota / 2);
const setText = (id, text) => {
const el = document.getElementById(id);
if (el) el.innerText = text;
};
setText('stat-total', totalTokens.toLocaleString());
setText('stat-active', activeTokens.toLocaleString());
setText('stat-cooling', coolingTokens.toLocaleString());
@@ -99,32 +181,50 @@ function updateStats(data) {
setText('stat-chat-quota', chatQuota.toLocaleString());
setText('stat-image-quota', imageQuota.toLocaleString());
setText('stat-total-calls', totalCalls.toLocaleString());
updateTabCounts({
all: totalTokens,
active: activeTokens,
cooling: coolingTokens,
expired: invalidTokens,
nsfw: nsfwTokens,
'no-nsfw': noNsfwTokens
});
}
function renderTable() {
const tbody = document.getElementById('token-table-body');
const loading = document.getElementById('loading');
const emptyState = document.getElementById('empty-state');
const tbody = byId('token-table-body');
const loading = byId('loading');
const emptyState = byId('empty-state');
tbody.innerHTML = '';
loading.classList.add('hidden');
// 更新 Tab 计数
updateTabCounts();
if (loading) loading.classList.add('hidden');
// 获取筛选后的列表
const filteredTokens = getFilteredTokens();
const { totalCount, totalPages, visibleTokens } = getPaginationData();
const indexByRef = new Map(flatTokens.map((t, i) => [t, i]));
if (filteredTokens.length === 0) {
updatePaginationControls(totalCount, totalPages);
if (visibleTokens.length === 0) {
tbody.replaceChildren();
if (emptyState) {
emptyState.textContent = currentFilter === 'all'
? '暂无 Token,请点击右上角导入或添加。'
: '当前筛选无结果,请切换筛选条件。';
}
emptyState.classList.remove('hidden');
updateSelectionState();
return;
}
emptyState.classList.add('hidden');
filteredTokens.forEach((item) => {
const fragment = document.createDocumentFragment();
visibleTokens.forEach((item) => {
// 获取原始索引用于操作
const originalIndex = flatTokens.indexOf(item);
const originalIndex = indexByRef.get(item);
const tr = document.createElement('tr');
tr.dataset.index = originalIndex;
if (item._selected) tr.classList.add('row-selected');
// Checkbox (Center)
const tdCheck = document.createElement('td');
@@ -199,33 +299,67 @@ function renderTable() {
tr.appendChild(tdNote);
tr.appendChild(tdActions);
tbody.appendChild(tr);
fragment.appendChild(tr);
});
tbody.replaceChildren(fragment);
updateSelectionState();
}
// Selection Logic
function toggleSelectAll() {
const checkbox = document.getElementById('select-all');
const checked = checkbox.checked;
// 只选择当前筛选后可见的 Token
getFilteredTokens().forEach(t => t._selected = checked);
renderTable();
const checkbox = byId('select-all');
const checked = !!(checkbox && checkbox.checked);
// 只选择当前页可见的 Token
setSelectedForTokens(getVisibleTokens(), checked);
syncVisibleSelectionUI(checked);
updateSelectionState();
}
function selectAllFiltered() {
const filtered = getFilteredTokens();
if (filtered.length === 0) return;
setSelectedForTokens(filtered, true);
syncVisibleSelectionUI(true);
updateSelectionState();
}
function selectVisibleAll() {
const visible = getVisibleTokens();
if (visible.length === 0) return;
setSelectedForTokens(visible, true);
syncVisibleSelectionUI(true);
updateSelectionState();
}
function clearAllSelection() {
if (flatTokens.length === 0) return;
setSelectedForTokens(flatTokens, false);
syncVisibleSelectionUI(false);
updateSelectionState();
}
function toggleSelect(index) {
flatTokens[index]._selected = !flatTokens[index]._selected;
const row = document.querySelector(`#token-table-body tr[data-index="${index}"]`);
if (row) row.classList.toggle('row-selected', flatTokens[index]._selected);
updateSelectionState();
}
function updateSelectionState() {
const selectedCount = flatTokens.filter(t => t._selected).length;
const allSelected = flatTokens.length > 0 && selectedCount === flatTokens.length;
document.getElementById('select-all').checked = allSelected;
document.getElementById('selected-count').innerText = selectedCount;
setActionButtonsState();
const selectedCount = countSelected(flatTokens);
const visible = getVisibleTokens();
const visibleSelected = countSelected(visible);
const selectAll = byId('select-all');
if (selectAll) {
const hasVisible = visible.length > 0;
selectAll.disabled = !hasVisible;
selectAll.checked = hasVisible && visibleSelected === visible.length;
selectAll.indeterminate = visibleSelected > 0 && visibleSelected < visible.length;
}
const selectedCountEl = byId('selected-count');
if (selectedCountEl) selectedCountEl.innerText = selectedCount;
setActionButtonsState(selectedCount);
}
// Actions
@@ -235,26 +369,17 @@ function addToken() {
// Batch export (Selected only)
function batchExport() {
const selected = flatTokens.filter(t => t._selected);
const selected = getSelectedTokens();
if (selected.length === 0) return showToast("未选择 Token", 'error');
let content = "";
selected.forEach(t => content += t.token + "\n");
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tokens_export_selected_${new Date().toISOString().slice(0, 10)}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
const content = selected.map(t => t.token).join('\n') + '\n';
downloadTextFile(content, `tokens_export_selected_${new Date().toISOString().slice(0, 10)}.txt`);
}
// Modal Logic
let currentEditIndex = -1;
function openEditModal(index) {
const modal = document.getElementById('edit-modal');
const modal = byId('edit-modal');
if (!modal) return;
currentEditIndex = index;
@@ -262,55 +387,49 @@ function openEditModal(index) {
if (index >= 0) {
// Edit existing
const item = flatTokens[index];
document.getElementById('edit-token-display').value = item.token;
document.getElementById('edit-original-token').value = item.token;
document.getElementById('edit-original-pool').value = item.pool;
document.getElementById('edit-pool').value = item.pool;
document.getElementById('edit-quota').value = item.quota;
document.getElementById('edit-note').value = item.note;
byId('edit-token-display').value = item.token;
byId('edit-original-token').value = item.token;
byId('edit-original-pool').value = item.pool;
byId('edit-pool').value = item.pool;
byId('edit-quota').value = item.quota;
byId('edit-note').value = item.note;
document.querySelector('#edit-modal h3').innerText = '编辑 Token';
} else {
// New Token
document.getElementById('edit-token-display').value = '';
document.getElementById('edit-token-display').disabled = false;
document.getElementById('edit-token-display').placeholder = 'sk-...';
document.getElementById('edit-token-display').classList.remove('bg-gray-50', 'text-gray-500');
const tokenInput = byId('edit-token-display');
tokenInput.value = '';
tokenInput.disabled = false;
tokenInput.placeholder = 'sk-...';
tokenInput.classList.remove('bg-gray-50', 'text-gray-500');
document.getElementById('edit-original-token').value = '';
document.getElementById('edit-original-pool').value = '';
document.getElementById('edit-pool').value = 'ssoBasic';
document.getElementById('edit-quota').value = 80;
document.getElementById('edit-note').value = '';
byId('edit-original-token').value = '';
byId('edit-original-pool').value = '';
byId('edit-pool').value = 'ssoBasic';
byId('edit-quota').value = 80;
byId('edit-note').value = '';
document.querySelector('#edit-modal h3').innerText = '添加 Token';
}
modal.classList.remove('hidden');
requestAnimationFrame(() => {
modal.classList.add('is-open');
});
openModal('edit-modal');
}
function closeEditModal() {
const modal = document.getElementById('edit-modal');
if (!modal) return;
modal.classList.remove('is-open');
setTimeout(() => {
modal.classList.add('hidden');
closeModal('edit-modal', () => {
// reset styles for token input
const input = document.getElementById('edit-token-display');
const input = byId('edit-token-display');
if (input) {
input.disabled = true;
input.classList.add('bg-gray-50', 'text-gray-500');
}
}, 200);
});
}
async function saveEdit() {
// Collect data
let token, pool, quota, note;
const newPool = document.getElementById('edit-pool').value.trim();
const newQuota = parseInt(document.getElementById('edit-quota').value) || 0;
const newNote = document.getElementById('edit-note').value.trim().slice(0, 50);
let token;
const newPool = byId('edit-pool').value.trim();
const newQuota = parseInt(byId('edit-quota').value) || 0;
const newNote = byId('edit-note').value.trim().slice(0, 50);
if (currentEditIndex >= 0) {
// Updating existing
@@ -323,7 +442,7 @@ async function saveEdit() {
item.note = newNote;
} else {
// Creating new
token = document.getElementById('edit-token-display').value.trim();
token = byId('edit-token-display').value.trim();
if (!token) return showToast('Token 不能为空', 'error');
// Check if exists
@@ -392,28 +511,19 @@ async function syncToServer() {
// Import Logic
function openImportModal() {
const modal = document.getElementById('import-modal');
if (!modal) return;
modal.classList.remove('hidden');
requestAnimationFrame(() => {
modal.classList.add('is-open');
});
openModal('import-modal');
}
function closeImportModal() {
const modal = document.getElementById('import-modal');
if (!modal) return;
modal.classList.remove('is-open');
setTimeout(() => {
modal.classList.add('hidden');
const input = document.getElementById('import-text');
closeModal('import-modal', () => {
const input = byId('import-text');
if (input) input.value = '';
}, 200);
});
}
async function submitImport() {
const pool = document.getElementById('import-pool').value.trim() || 'ssoBasic';
const text = document.getElementById('import-text').value;
const pool = byId('import-pool').value.trim() || 'ssoBasic';
const text = byId('import-text').value;
const lines = text.split('\n');
lines.forEach(line => {
@@ -438,19 +548,9 @@ async function submitImport() {
// Export Logic
function exportTokens() {
let content = "";
flatTokens.forEach(t => content += t.token + "\n");
if (!content) return showToast("列表为空", 'error');
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tokens_export_${new Date().toISOString().slice(0, 10)}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
if (flatTokens.length === 0) return showToast("列表为空", 'error');
const content = flatTokens.map(t => t.token).join('\n') + '\n';
downloadTextFile(content, `tokens_export_${new Date().toISOString().slice(0, 10)}.txt`);
}
async function copyToClipboard(text, btn) {
@@ -507,13 +607,14 @@ async function refreshStatus(token) {
}
}
async function startBatchRefresh() {
if (isBatchProcessing) {
showToast('当前有任务进行中', 'info');
return;
}
const selected = flatTokens.filter(t => t._selected);
const selected = getSelectedTokens();
if (selected.length === 0) return showToast("未选择 Token", 'error');
// Init state
@@ -526,70 +627,88 @@ async function startBatchRefresh() {
updateBatchProgress();
setActionButtonsState();
processBatchQueue();
}
async function processBatchQueue() {
if (!isBatchProcessing || isBatchPaused || currentBatchAction !== 'refresh') return;
if (batchQueue.length === 0) {
// Done
finishBatchProcess();
return;
}
// Take chunk
const chunk = batchQueue.splice(0, BATCH_SIZE);
try {
const res = await fetch('/api/v1/admin/tokens/refresh', {
const res = await fetch('/api/v1/admin/tokens/refresh/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens: chunk })
body: JSON.stringify({ tokens: batchQueue })
});
if (res.ok) {
batchProcessed += chunk.length;
} else {
showToast('部分刷新失败', 'error');
batchProcessed += chunk.length;
const data = await res.json();
if (!res.ok || data.status !== 'success') {
throw new Error(data.detail || '请求失败');
}
} catch (e) {
showToast('网络请求错误', 'error');
batchProcessed += chunk.length;
}
updateBatchProgress();
// Recursive call for next batch
// Small delay to allow UI updates and interactions
if (!isBatchProcessing || isBatchPaused) return;
setTimeout(() => {
processBatchQueue();
}, 400);
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') batchTotal = msg.total;
if (typeof msg.processed === 'number') batchProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') batchTotal = msg.total;
batchProcessed = batchTotal;
updateBatchProgress();
finishBatchProcess(false, { silent: true });
if (msg.warning) {
showToast(`刷新完成\n⚠️ ${msg.warning}`, 'warning');
} else {
showToast('刷新完成', 'success');
}
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'cancelled') {
finishBatchProcess(true, { silent: true });
showToast('已终止刷新', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'error') {
finishBatchProcess(true, { silent: true });
showToast('刷新失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
},
onError: () => {
finishBatchProcess(true, { silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
});
} catch (e) {
finishBatchProcess(true, { silent: true });
showToast(e.message || '请求失败', 'error');
currentBatchTaskId = null;
}
}
function toggleBatchPause() {
if (!isBatchProcessing) return;
isBatchPaused = !isBatchPaused;
updateBatchProgress();
if (!isBatchPaused) {
if (currentBatchAction === 'refresh') {
processBatchQueue();
} else if (currentBatchAction === 'delete') {
processDeleteQueue();
}
}
showToast('当前任务不支持暂停', 'info');
}
function stopBatchRefresh() {
if (!isBatchProcessing) return;
if (currentBatchTaskId) {
BatchSSE.cancel(currentBatchTaskId, apiKey);
BatchSSE.close(batchEventSource);
batchEventSource = null;
currentBatchTaskId = null;
}
finishBatchProcess(true);
}
function finishBatchProcess(aborted = false) {
function finishBatchProcess(aborted = false, options = {}) {
const action = currentBatchAction;
isBatchProcessing = false;
isBatchPaused = false;
@@ -601,10 +720,23 @@ function finishBatchProcess(aborted = false) {
updateSelectionState();
loadData(); // Final data refresh
if (options.silent) return;
if (aborted) {
showToast(action === 'delete' ? '已终止删除' : '已终止刷新', 'info');
if (action === 'delete') {
showToast('已终止删除', 'info');
} else if (action === 'nsfw') {
showToast('已终止 NSFW', 'info');
} else {
showToast('已终止刷新', 'info');
}
} else {
showToast(action === 'delete' ? '删除完成' : '刷新完成', 'success');
if (action === 'delete') {
showToast('删除完成', 'success');
} else if (action === 'nsfw') {
showToast('NSFW 开启完成', 'success');
} else {
showToast('刷新完成', 'success');
}
}
}
@@ -613,10 +745,10 @@ async function batchUpdate() {
}
function updateBatchProgress() {
const container = document.getElementById('batch-progress');
const text = document.getElementById('batch-progress-text');
const pauseBtn = document.getElementById('btn-pause-action');
const stopBtn = document.getElementById('btn-stop-action');
const container = byId('batch-progress');
const text = byId('batch-progress-text');
const pauseBtn = byId('btn-pause-action');
const stopBtn = byId('btn-stop-action');
if (!container || !text) return;
if (!isBatchProcessing) {
container.classList.add('hidden');
@@ -628,21 +760,25 @@ function updateBatchProgress() {
text.textContent = `${pct}%`;
container.classList.remove('hidden');
if (pauseBtn) {
pauseBtn.textContent = isBatchPaused ? '继续' : '暂停';
pauseBtn.classList.remove('hidden');
pauseBtn.classList.add('hidden');
}
if (stopBtn) stopBtn.classList.remove('hidden');
}
function setActionButtonsState() {
const selectedCount = flatTokens.filter(t => t._selected).length;
function setActionButtonsState(selectedCount = null) {
let count = selectedCount;
if (count === null) {
count = countSelected(flatTokens);
}
const disabled = isBatchProcessing;
const exportBtn = document.getElementById('btn-batch-export');
const updateBtn = document.getElementById('btn-batch-update');
const deleteBtn = document.getElementById('btn-batch-delete');
if (exportBtn) exportBtn.disabled = disabled || selectedCount === 0;
if (updateBtn) updateBtn.disabled = disabled || selectedCount === 0;
if (deleteBtn) deleteBtn.disabled = disabled || selectedCount === 0;
const exportBtn = byId('btn-batch-export');
const updateBtn = byId('btn-batch-update');
const nsfwBtn = byId('btn-batch-nsfw');
const deleteBtn = byId('btn-batch-delete');
if (exportBtn) exportBtn.disabled = disabled || count === 0;
if (updateBtn) updateBtn.disabled = disabled || count === 0;
if (nsfwBtn) nsfwBtn.disabled = disabled || count === 0;
if (deleteBtn) deleteBtn.disabled = disabled || count === 0;
}
async function startBatchDelete() {
@@ -650,7 +786,7 @@ async function startBatchDelete() {
showToast('当前有任务进行中', 'info');
return;
}
const selected = flatTokens.filter(t => t._selected);
const selected = getSelectedTokens();
if (selected.length === 0) return showToast("未选择 Token", 'error');
const ok = await confirmAction(`确定要删除选中的 ${selected.length} 个 Token 吗?`, { okText: '删除' });
if (!ok) return;
@@ -664,16 +800,28 @@ async function startBatchDelete() {
updateBatchProgress();
setActionButtonsState();
processDeleteQueue();
try {
const toRemove = new Set(batchQueue);
flatTokens = flatTokens.filter(t => !toRemove.has(t.token));
await syncToServer();
batchProcessed = batchTotal;
updateBatchProgress();
finishBatchProcess(false, { silent: true });
showToast('删除完成', 'success');
} catch (e) {
finishBatchProcess(true, { silent: true });
showToast('删除失败', 'error');
}
}
let confirmResolver = null;
function setupConfirmDialog() {
const dialog = document.getElementById('confirm-dialog');
const dialog = byId('confirm-dialog');
if (!dialog) return;
const okBtn = document.getElementById('confirm-ok');
const cancelBtn = document.getElementById('confirm-cancel');
const okBtn = byId('confirm-ok');
const cancelBtn = byId('confirm-cancel');
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
closeConfirm(false);
@@ -684,13 +832,13 @@ function setupConfirmDialog() {
}
function confirmAction(message, options = {}) {
const dialog = document.getElementById('confirm-dialog');
const dialog = byId('confirm-dialog');
if (!dialog) {
return Promise.resolve(false);
}
const messageEl = document.getElementById('confirm-message');
const okBtn = document.getElementById('confirm-ok');
const cancelBtn = document.getElementById('confirm-cancel');
const messageEl = byId('confirm-message');
const okBtn = byId('confirm-ok');
const cancelBtn = byId('confirm-cancel');
if (messageEl) messageEl.textContent = message;
if (okBtn) okBtn.textContent = options.okText || '确定';
if (cancelBtn) cancelBtn.textContent = options.cancelText || '取消';
@@ -704,7 +852,7 @@ function confirmAction(message, options = {}) {
}
function closeConfirm(ok) {
const dialog = document.getElementById('confirm-dialog');
const dialog = byId('confirm-dialog');
if (!dialog) return;
dialog.classList.remove('is-open');
setTimeout(() => {
@@ -716,29 +864,6 @@ function closeConfirm(ok) {
}, 200);
}
async function processDeleteQueue() {
if (!isBatchProcessing || isBatchPaused || currentBatchAction !== 'delete') return;
if (batchQueue.length === 0) {
finishBatchProcess();
return;
}
const chunk = batchQueue.splice(0, BATCH_SIZE);
const toRemove = new Set(chunk);
flatTokens = flatTokens.filter(t => !toRemove.has(t.token));
try {
await syncToServer();
batchProcessed += chunk.length;
} catch (e) {
showToast('删除失败', 'error');
batchProcessed += chunk.length;
}
updateBatchProgress();
if (!isBatchProcessing || isBatchPaused) return;
setTimeout(() => {
processDeleteQueue();
}, 400);
}
function escapeHtml(text) {
if (!text) return '';
return text
@@ -753,6 +878,7 @@ function escapeHtml(text) {
function filterByStatus(status) {
currentFilter = status;
currentPage = 1;
// 更新 Tab 样式和 ARIA
document.querySelectorAll('.tab-item').forEach(tab => {
@@ -777,8 +903,8 @@ function getFilteredTokens() {
});
}
function updateTabCounts() {
const counts = {
function updateTabCounts(counts) {
const safeCounts = counts || {
all: flatTokens.length,
active: flatTokens.filter(t => t.status === 'active').length,
cooling: flatTokens.filter(t => t.status === 'cooling').length,
@@ -787,12 +913,56 @@ function updateTabCounts() {
'no-nsfw': flatTokens.filter(t => !t.tags || !t.tags.includes('nsfw')).length
};
Object.entries(counts).forEach(([key, count]) => {
const el = document.getElementById(`tab-count-${key}`);
Object.entries(safeCounts).forEach(([key, count]) => {
const el = byId(`tab-count-${key}`);
if (el) el.textContent = count;
});
}
function getVisibleTokens() {
return getPaginationData().visibleTokens;
}
function updatePaginationControls(totalCount, totalPages) {
const info = byId('pagination-info');
const prevBtn = byId('page-prev');
const nextBtn = byId('page-next');
const sizeSelect = byId('page-size');
if (sizeSelect && String(sizeSelect.value) !== String(pageSize)) {
sizeSelect.value = String(pageSize);
}
if (info) {
info.textContent = `第 ${totalCount === 0 ? 0 : currentPage} / ${totalPages} 页 · 共 ${totalCount} 条`;
}
if (prevBtn) prevBtn.disabled = totalCount === 0 || currentPage <= 1;
if (nextBtn) nextBtn.disabled = totalCount === 0 || currentPage >= totalPages;
}
function goPrevPage() {
if (currentPage <= 1) return;
currentPage -= 1;
renderTable();
}
function goNextPage() {
const totalCount = getFilteredTokens().length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (currentPage >= totalPages) return;
currentPage += 1;
renderTable();
}
function changePageSize() {
const sizeSelect = byId('page-size');
const value = sizeSelect ? parseInt(sizeSelect.value, 10) : 0;
if (!value || value === pageSize) return;
pageSize = value;
currentPage = 1;
renderTable();
}
// ========== NSFW 批量开启 ==========
async function batchEnableNSFW() {
@@ -801,27 +971,31 @@ async function batchEnableNSFW() {
return;
}
const selected = flatTokens.filter(t => t._selected);
const targetCount = selected.length || flatTokens.length;
const msg = selected.length === 0
? `是否对全部 ${targetCount} 个 Token 开启 NSFW 模式?`
: `是否为选中的 ${selected.length} 个 Token 开启 NSFW 模式?`;
const selected = getSelectedTokens();
const targetCount = selected.length;
if (targetCount === 0) {
showToast('未选择 Token', 'error');
return;
}
const msg = `是否为选中的 ${targetCount} 个 Token 开启 NSFW 模式?`;
const ok = await confirmAction(msg, { okText: '开启 NSFW' });
if (!ok) return;
// 禁用按钮
const btn = document.getElementById('btn-batch-nsfw');
const btn = byId('btn-batch-nsfw');
if (btn) btn.disabled = true;
isBatchProcessing = true;
currentBatchAction = 'nsfw';
batchTotal = targetCount;
batchProcessed = 0;
updateBatchProgress();
setActionButtonsState();
try {
const tokens = selected.length > 0 ? selected.map(t => t.token) : null;
const res = await fetch('/api/v1/admin/tokens/nsfw/enable', {
const res = await fetch('/api/v1/admin/tokens/nsfw/enable/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -831,26 +1005,66 @@ async function batchEnableNSFW() {
});
const data = await res.json();
if (res.ok && data.status === 'success') {
const { ok: okCount, fail } = data.summary;
let msg = `NSFW 开启完成:成功 ${okCount},失败 ${fail}`;
if (data.warning) {
msg += `\n⚠️ ${data.warning}`;
}
showToast(msg, fail > 0 || data.warning ? 'warning' : 'success');
// 刷新数据以显示新的 nsfw 标签
loadData();
} else {
showToast('开启失败: ' + (data.detail || '未知错误'), 'error');
if (!res.ok || data.status !== 'success') {
throw new Error(data.detail || '请求失败');
}
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') batchTotal = msg.total;
if (typeof msg.processed === 'number') batchProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') batchTotal = msg.total;
batchProcessed = batchTotal;
updateBatchProgress();
finishBatchProcess(false, { silent: true });
const summary = msg.result && msg.result.summary ? msg.result.summary : null;
const okCount = summary ? summary.ok : 0;
const failCount = summary ? summary.fail : 0;
let text = `NSFW 开启完成:成功 ${okCount},失败 ${failCount}`;
if (msg.warning) text += `\n⚠️ ${msg.warning}`;
showToast(text, failCount > 0 || msg.warning ? 'warning' : 'success');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
if (btn) btn.disabled = false;
setActionButtonsState();
} else if (msg.type === 'cancelled') {
finishBatchProcess(true, { silent: true });
showToast('已终止 NSFW', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
if (btn) btn.disabled = false;
setActionButtonsState();
} else if (msg.type === 'error') {
finishBatchProcess(true, { silent: true });
showToast('开启失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
if (btn) btn.disabled = false;
setActionButtonsState();
}
},
onError: () => {
finishBatchProcess(true, { silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
if (btn) btn.disabled = false;
setActionButtonsState();
}
});
} catch (e) {
finishBatchProcess(true, { silent: true });
showToast('请求错误: ' + e.message, 'error');
} finally {
isBatchProcessing = false;
currentBatchAction = null;
if (btn) btn.disabled = false;
updateBatchProgress();
setActionButtonsState();
}
}
+5 -2
View File
@@ -34,7 +34,10 @@ assets_max_concurrent = 25
media_max_concurrent = 50
usage_max_concurrent = 25
assets_delete_batch_size = 10
admin_assets_batch_size = 10
assets_batch_size = 10
assets_max_tokens = 1000
usage_batch_size = 50
usage_max_tokens = 1000
nsfw_max_concurrent = 10
admin_nsfw_batch_size = 50
nsfw_batch_size = 50
nsfw_max_tokens = 1000
+7 -1
View File
@@ -34,4 +34,10 @@ assets_max_concurrent = 25
media_max_concurrent = 50
usage_max_concurrent = 25
assets_delete_batch_size = 10
admin_assets_batch_size = 10
assets_batch_size = 10
assets_max_tokens = 1000
usage_batch_size = 50
usage_max_tokens = 1000
nsfw_max_concurrent = 10
nsfw_batch_size = 50
nsfw_max_tokens = 1000
+10 -4
View File
@@ -174,11 +174,17 @@ Config file: `data/config.toml`
| | `reload_interval_sec` | Consistency refresh | Token state refresh interval in multi-worker setups (sec). | `30` |
| **cache** | `enable_auto_clean` | Auto clean | Enable cache auto clean; cleanup when exceeding limit. | `true` |
| | `limit_mb` | Cleanup threshold | Cache size threshold (MB) that triggers cleanup. | `1024` |
| **performance** | `assets_max_concurrent` | Assets concurrency | Concurrency cap for assets upload/download/list. Recommended 25. | `25` |
| **performance** | `nsfw_max_concurrent` | NSFW enable concurrency | Concurrency cap for enabling NSFW in batch. Recommended 10. | `10` |
| | `nsfw_batch_size` | NSFW enable batch size | Batch size for enabling NSFW. Recommended 50. | `50` |
| | `nsfw_max_tokens` | NSFW enable max tokens | Max tokens per NSFW batch to avoid mistakes. Recommended 1000. | `1000` |
| | `usage_max_concurrent` | Token usage refresh concurrency | Concurrency cap for batch usage refresh. Recommended 25. | `25` |
| | `usage_batch_size` | Token usage refresh batch size | Batch size for usage refresh. Recommended 50. | `50` |
| | `usage_max_tokens` | Token usage refresh max tokens | Max tokens per usage refresh batch. Recommended 1000. | `1000` |
| | `assets_max_concurrent` | Online assets find/delete concurrency | Concurrency cap for online asset find/delete. Recommended 25. | `25` |
| | `assets_batch_size` | Online assets find/delete batch size | Batch size for online asset find/delete. Recommended 10. | `10` |
| | `assets_max_tokens` | Online assets find/delete max tokens | Max tokens per online asset find/delete batch. Recommended 1000. | `1000` |
| | `assets_delete_batch_size` | Online assets delete batch | Batch concurrency for online asset deletion. Recommended 10. | `10` |
| | `media_max_concurrent` | Media concurrency | Concurrency cap for video/media generation. Recommended 50. | `50` |
| | `usage_max_concurrent` | Usage concurrency | Concurrency cap for usage queries. Recommended 25. | `25` |
| | `assets_delete_batch_size` | Asset cleanup batch | Batch concurrency for online asset deletion. Recommended 10. | `10` |
| | `admin_assets_batch_size` | Admin cleanup batch | Batch concurrency for admin asset stats/cleanup. Recommended 10. | `10` |
<br>
+11 -8
View File
@@ -205,14 +205,17 @@ curl http://localhost:8000/v1/images/generations \
| | `reload_interval_sec` | 一致性刷新 | 多 worker 场景下 Token 状态刷新间隔(秒)。 | `30` |
| **cache** | `enable_auto_clean` | 自动清理 | 是否启用缓存自动清理,开启后按上限自动回收。 | `true` |
| | `limit_mb` | 清理阈值 | 缓存大小阈值(MB),超过阈值会触发清理。 | `1024` |
| **performance** | `assets_max_concurrent` | 资产并发上限 | 资源上传/下载/列表的并发上限。推荐 25。 | `25` |
| | `media_max_concurrent` | 媒体并发上限 | 视频/媒体生成请求的并发上限。推荐 50。 | `50` |
| | `usage_max_concurrent` | 用量并发上限 | 用量查询请求的并发上限。推荐 25。 | `25` |
| | `assets_delete_batch_size` | 资产清理批量 | 在线资产删除单批并发数量。推荐 10。 | `10` |
| | `admin_assets_batch_size` | 管理端批量 | 管理端在线资产统计/清理批量并发数量。推荐 10。 | `10` |
| | `nsfw_max_concurrent` | NSFW 并发上限 | 批量开启 NSFW 模式的并发请求数。推荐 10。 | `10` |
| | `admin_nsfw_batch_size` | NSFW 批量大小 | 管理端批量开启 NSFW 的单批处理数量。推荐 50。 | `50` |
| | `nsfw_max_tokens` | NSFW 最大数量 | 单次批量开启 NSFW 的 Token 数量上限。推荐 1000。 | `1000` |
| **performance** | `nsfw_max_concurrent` | 开启 NSFW 模式并发上限 | 批量开启 NSFW 模式时的并发请求上限。推荐 10。 | `10` |
| | `nsfw_batch_size` | 开启 NSFW 模式批次大小 | 批量开启 NSFW 模式的单批处理数量。推荐 50。 | `50` |
| | `nsfw_max_tokens` | 开启 NSFW 模式最大数量 | 单次批量开启 NSFW 的 Token 数量上限,防止误操作。推荐 1000。 | `1000` |
| | `usage_max_concurrent` | Token 用量刷新并发上限 | 批量刷新 Token 用量时的并发请求上限。推荐 25。 | `25` |
| | `usage_batch_size` | Token 用量刷新批次大小 | 批量刷新 Token 用量的单批处理数量。推荐 50。 | `50` |
| | `usage_max_tokens` | Token 用量刷新最大数量 | 批量刷新 Token 用量的单次处理数量上限。推荐 1000。 | `1000` |
| | `assets_max_concurrent` | 在线资产查找/删除并发上限 | 在线资产查找/删除时的并发请求上限。推荐 25。 | `25` |
| | `assets_batch_size` | 在线资产查找/删除批次大小 | 在线资产查找/删除的单批处理数量。推荐 10。 | `10` |
| | `assets_max_tokens` | 在线资产查找/删除最大数量 | 在线资产查找/删除的单次处理数量上限。推荐 1000。 | `1000` |
| | `assets_delete_batch_size` | 在线资产删除批量 | 在线资产删除单批并发数量。推荐 10。 | `10` |
| | `media_max_concurrent` | 媒体并发上限 | 视频/媒体生成请求的并发上限。推荐 50。 | `50` |
<br>