feat: optimize frontend batch processing logic
This commit is contained in:
1 parent
8e23fdf9c7
commit
b11aace053
21 files changed
+2094
-756
No files matched your search
+729
-45
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Vendored
+7
-6
@@ -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>
|
||||
|
||||
|
||||
Vendored
+424
-307
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user