feat(admin): 优化 NSFW 功能和 Token 管理界面
NSFW 功能优化: - 修复成功判断逻辑:HTTP 200 且无 grpc-status 视为成功 - 成功后自动添加 nsfw 标签到 Token - 超出数量限制时截取前 N 个处理,而非报错 - 完成后显示警告提示和自动刷新列表 Token 管理界面: - 状态列显示 nsfw 紫色标签 - 新增「已开 NSFW」和「未开 NSFW」筛选 Tab - Tab 分隔线区分状态筛选和 NSFW 筛选 配置管理界面: - 添加 NSFW 相关配置的中文名称和说明 - 更新 JS 版本号强制刷新缓存 Token 管理器: - 添加 add_tag/remove_tag 方法
This commit is contained in:
1 parent
41d5a8d48f
commit
dd323a67f2
8 files changed
+223
-106
No files matched your search
+18
-5
@@ -181,16 +181,20 @@ async def enable_nsfw_api(data: dict):
|
||||
# 去重并保持顺序
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
|
||||
# 限制最大数量(防止误操作)
|
||||
# 限制最大数量(超出时截取前 N 个)
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Too many tokens: {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"
|
||||
)
|
||||
|
||||
# 批量执行配置
|
||||
@@ -200,6 +204,9 @@ async def enable_nsfw_api(data: dict):
|
||||
# 定义 worker
|
||||
async def _enable(token: str):
|
||||
result = await nsfw_service.enable(token)
|
||||
# 成功后添加 nsfw tag
|
||||
if result.success:
|
||||
await mgr.add_tag(token, "nsfw")
|
||||
return {
|
||||
"success": result.success,
|
||||
"http_status": result.http_status,
|
||||
@@ -227,7 +234,7 @@ async def enable_nsfw_api(data: dict):
|
||||
fail_count += 1
|
||||
results[masked] = res.get("data") or {"error": res.get("error")}
|
||||
|
||||
return {
|
||||
response = {
|
||||
"status": "success",
|
||||
"summary": {
|
||||
"total": len(unique_tokens),
|
||||
@@ -237,6 +244,12 @@ async def enable_nsfw_api(data: dict):
|
||||
"results": results,
|
||||
}
|
||||
|
||||
# 添加截断提示
|
||||
if truncated:
|
||||
response["warning"] = f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -97,8 +97,11 @@ class NSFWService:
|
||||
|
||||
grpc_status = get_grpc_status(trailers)
|
||||
|
||||
# HTTP 200 且无 grpc-status(空响应)或 grpc-status=0 都算成功
|
||||
success = grpc_status.code == -1 or grpc_status.ok
|
||||
|
||||
return NSFWResult(
|
||||
success=grpc_status.ok,
|
||||
success=success,
|
||||
http_status=response.status_code,
|
||||
grpc_status=grpc_status.code,
|
||||
grpc_message=grpc_status.message or None,
|
||||
|
||||
+150
-88
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.core.logger import logger
|
||||
from app.services.token.models import TokenInfo, EffortType, TokenPoolStats, FAIL_THRESHOLD, TokenStatus
|
||||
from app.services.token.models import TokenInfo, EffortType, FAIL_THRESHOLD, TokenStatus
|
||||
from app.core.storage import get_storage
|
||||
from app.core.config import get_config
|
||||
from app.services.token.pool import TokenPool
|
||||
@@ -19,10 +19,10 @@ REFRESH_CONCURRENCY = 5
|
||||
|
||||
class TokenManager:
|
||||
"""管理 Token 的增删改查和配额同步"""
|
||||
|
||||
|
||||
_instance: Optional["TokenManager"] = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.pools: Dict[str, TokenPool] = {}
|
||||
self.initialized = False
|
||||
@@ -31,7 +31,7 @@ class TokenManager:
|
||||
self._save_task: Optional[asyncio.Task] = None
|
||||
self._save_delay = 0.5
|
||||
self._last_reload_at = 0.0
|
||||
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "TokenManager":
|
||||
"""获取单例实例"""
|
||||
@@ -48,16 +48,19 @@ class TokenManager:
|
||||
try:
|
||||
storage = get_storage()
|
||||
data = await storage.load_tokens()
|
||||
|
||||
|
||||
# 如果后端返回 None 或空数据,尝试从本地 data/token.json 初始化后端
|
||||
if not data:
|
||||
from app.core.storage import LocalStorage
|
||||
|
||||
local_storage = LocalStorage()
|
||||
local_data = await local_storage.load_tokens()
|
||||
if local_data:
|
||||
data = local_data
|
||||
await storage.save_tokens(local_data)
|
||||
logger.info(f"Initialized remote token storage ({storage.__class__.__name__}) with local tokens.")
|
||||
logger.info(
|
||||
f"Initialized remote token storage ({storage.__class__.__name__}) with local tokens."
|
||||
)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
@@ -69,20 +72,26 @@ class TokenManager:
|
||||
# 统一存储裸 token
|
||||
if isinstance(token_data, dict):
|
||||
raw_token = token_data.get("token")
|
||||
if isinstance(raw_token, str) and raw_token.startswith("sso="):
|
||||
if isinstance(raw_token, str) and raw_token.startswith(
|
||||
"sso="
|
||||
):
|
||||
token_data["token"] = raw_token[4:]
|
||||
token_info = TokenInfo(**token_data)
|
||||
pool.add(token_info)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load token in pool '{pool_name}': {e}")
|
||||
logger.warning(
|
||||
f"Failed to load token in pool '{pool_name}': {e}"
|
||||
)
|
||||
continue
|
||||
pool._rebuild_index()
|
||||
self.pools[pool_name] = pool
|
||||
|
||||
|
||||
self.initialized = True
|
||||
self._last_reload_at = time.monotonic()
|
||||
total = sum(p.count() for p in self.pools.values())
|
||||
logger.info(f"TokenManager initialized: {len(self.pools)} pools with {total} tokens")
|
||||
logger.info(
|
||||
f"TokenManager initialized: {len(self.pools)} pools with {total} tokens"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize TokenManager: {e}")
|
||||
self.pools = {}
|
||||
@@ -113,10 +122,8 @@ class TokenManager:
|
||||
try:
|
||||
data = {}
|
||||
for pool_name, pool in self.pools.items():
|
||||
data[pool_name] = [
|
||||
info.model_dump() for info in pool.list()
|
||||
]
|
||||
|
||||
data[pool_name] = [info.model_dump() for info in pool.list()]
|
||||
|
||||
storage = get_storage()
|
||||
async with storage.acquire_lock("tokens_save", timeout=10):
|
||||
await storage.save_tokens(data)
|
||||
@@ -153,13 +160,14 @@ class TokenManager:
|
||||
self._save_task = None
|
||||
if self._dirty:
|
||||
self._schedule_save()
|
||||
|
||||
def get_token(self, pool_name: str = "ssoBasic") -> Optional[str]:
|
||||
"""
|
||||
获取可用 Token
|
||||
|
||||
|
||||
Args:
|
||||
pool_name: Token 池名称
|
||||
|
||||
|
||||
Returns:
|
||||
Token 字符串或 None
|
||||
"""
|
||||
@@ -167,73 +175,77 @@ class TokenManager:
|
||||
if not pool:
|
||||
logger.warning(f"Pool '{pool_name}' not found")
|
||||
return None
|
||||
|
||||
|
||||
token_info = pool.select()
|
||||
if not token_info:
|
||||
logger.warning(f"No available token in pool '{pool_name}'")
|
||||
return None
|
||||
|
||||
|
||||
token = token_info.token
|
||||
if token.startswith("sso="):
|
||||
return token[4:]
|
||||
return token
|
||||
|
||||
async def consume(self, token_str: str, effort: EffortType = EffortType.LOW) -> bool:
|
||||
async def consume(
|
||||
self, token_str: str, effort: EffortType = EffortType.LOW
|
||||
) -> bool:
|
||||
"""
|
||||
消耗配额(本地预估)
|
||||
|
||||
|
||||
Args:
|
||||
token_str: Token 字符串
|
||||
effort: 消耗力度
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token_str.replace("sso=", "")
|
||||
|
||||
|
||||
for pool in self.pools.values():
|
||||
token = pool.get(raw_token)
|
||||
if token:
|
||||
consumed = token.consume(effort)
|
||||
logger.debug(f"Token {raw_token[:10]}...: consumed {consumed} quota, use_count={token.use_count}")
|
||||
logger.debug(
|
||||
f"Token {raw_token[:10]}...: consumed {consumed} quota, use_count={token.use_count}"
|
||||
)
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"Token {raw_token[:10]}...: not found for consumption")
|
||||
return False
|
||||
|
||||
async def sync_usage(
|
||||
self,
|
||||
token_str: str,
|
||||
model_name: str,
|
||||
self,
|
||||
token_str: str,
|
||||
model_name: str,
|
||||
fallback_effort: EffortType = EffortType.LOW,
|
||||
consume_on_fail: bool = True,
|
||||
is_usage: bool = True
|
||||
is_usage: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
同步 Token 用量
|
||||
|
||||
|
||||
优先从 API 获取最新配额,失败则降级到本地预估
|
||||
|
||||
|
||||
Args:
|
||||
token_str: Token 字符串(可带 sso= 前缀)
|
||||
model_name: 模型名称(用于 API 查询)
|
||||
fallback_effort: 降级时的消耗力度
|
||||
consume_on_fail: 失败时是否降级扣费
|
||||
is_usage: 是否记录为一次使用(影响 use_count)
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token_str.replace("sso=", "")
|
||||
|
||||
|
||||
# 查找 Token 对象
|
||||
target_token: Optional[TokenInfo] = None
|
||||
for pool in self.pools.values():
|
||||
target_token = pool.get(raw_token)
|
||||
if target_token:
|
||||
break
|
||||
|
||||
|
||||
if not target_token:
|
||||
logger.warning(f"Token {raw_token[:10]}...: not found for sync")
|
||||
return False
|
||||
@@ -241,51 +253,57 @@ class TokenManager:
|
||||
# 尝试 API 同步
|
||||
try:
|
||||
from app.services.grok.usage import UsageService
|
||||
|
||||
|
||||
usage_service = UsageService()
|
||||
result = await usage_service.get(token_str, model_name=model_name)
|
||||
|
||||
|
||||
if result and "remainingTokens" in result:
|
||||
old_quota = target_token.quota
|
||||
new_quota = result["remainingTokens"]
|
||||
|
||||
|
||||
target_token.update_quota(new_quota)
|
||||
target_token.record_success(is_usage=is_usage)
|
||||
|
||||
|
||||
consumed = max(0, old_quota - new_quota)
|
||||
logger.info(
|
||||
f"Token {raw_token[:10]}...: synced quota "
|
||||
f"{old_quota} -> {new_quota} (consumed: {consumed}, use_count: {target_token.use_count})"
|
||||
)
|
||||
|
||||
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Token {raw_token[:10]}...: API sync failed, fallback to local ({e})")
|
||||
|
||||
logger.warning(
|
||||
f"Token {raw_token[:10]}...: API sync failed, fallback to local ({e})"
|
||||
)
|
||||
|
||||
# 降级:本地预估扣费
|
||||
if consume_on_fail:
|
||||
logger.debug(f"Token {raw_token[:10]}...: using local consumption")
|
||||
return await self.consume(token_str, fallback_effort)
|
||||
else:
|
||||
logger.debug(f"Token {raw_token[:10]}...: sync failed, skipping local consumption")
|
||||
logger.debug(
|
||||
f"Token {raw_token[:10]}...: sync failed, skipping local consumption"
|
||||
)
|
||||
return False
|
||||
|
||||
async def record_fail(self, token_str: str, status_code: int = 401, reason: str = "") -> bool:
|
||||
async def record_fail(
|
||||
self, token_str: str, status_code: int = 401, reason: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
记录 Token 失败
|
||||
|
||||
|
||||
Args:
|
||||
token_str: Token 字符串
|
||||
status_code: HTTP 状态码
|
||||
reason: 失败原因
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token_str.replace("sso=", "")
|
||||
|
||||
|
||||
for pool in self.pools.values():
|
||||
token = pool.get(raw_token)
|
||||
if token:
|
||||
@@ -301,7 +319,7 @@ class TokenManager:
|
||||
)
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"Token {raw_token[:10]}...: not found for failure record")
|
||||
return False
|
||||
|
||||
@@ -310,25 +328,25 @@ class TokenManager:
|
||||
async def add(self, token: str, pool_name: str = "ssoBasic") -> bool:
|
||||
"""
|
||||
添加 Token
|
||||
|
||||
|
||||
Args:
|
||||
token: Token 字符串(不含 sso= 前缀)
|
||||
pool_name: 池名称
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if pool_name not in self.pools:
|
||||
self.pools[pool_name] = TokenPool(pool_name)
|
||||
logger.info(f"Pool '{pool_name}': created")
|
||||
|
||||
|
||||
pool = self.pools[pool_name]
|
||||
|
||||
|
||||
token = token[4:] if token.startswith("sso=") else token
|
||||
if pool.get(token):
|
||||
logger.warning(f"Pool '{pool_name}': token already exists")
|
||||
return False
|
||||
|
||||
|
||||
pool.add(TokenInfo(token=token))
|
||||
await self._save()
|
||||
logger.info(f"Pool '{pool_name}': token added")
|
||||
@@ -345,13 +363,57 @@ class TokenManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def add_tag(self, token: str, tag: str) -> bool:
|
||||
"""
|
||||
给 Token 添加标签
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
tag: 标签名称
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token[4:] if token.startswith("sso=") else token
|
||||
for pool in self.pools.values():
|
||||
info = pool.get(raw_token)
|
||||
if info:
|
||||
if tag not in info.tags:
|
||||
info.tags.append(tag)
|
||||
self._schedule_save()
|
||||
logger.debug(f"Token {raw_token[:10]}...: added tag '{tag}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def remove_tag(self, token: str, tag: str) -> bool:
|
||||
"""
|
||||
移除 Token 标签
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
tag: 标签名称
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token[4:] if token.startswith("sso=") else token
|
||||
for pool in self.pools.values():
|
||||
info = pool.get(raw_token)
|
||||
if info:
|
||||
if tag in info.tags:
|
||||
info.tags.remove(tag)
|
||||
self._schedule_save()
|
||||
logger.debug(f"Token {raw_token[:10]}...: removed tag '{tag}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def remove(self, token: str) -> bool:
|
||||
"""
|
||||
删除 Token
|
||||
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
@@ -360,8 +422,8 @@ class TokenManager:
|
||||
await self._save()
|
||||
logger.info(f"Pool '{pool_name}': token removed")
|
||||
return True
|
||||
|
||||
logger.warning(f"Token not found for removal")
|
||||
|
||||
logger.warning("Token not found for removal")
|
||||
return False
|
||||
|
||||
async def reset_all(self):
|
||||
@@ -371,22 +433,22 @@ class TokenManager:
|
||||
for token in pool:
|
||||
token.reset()
|
||||
count += 1
|
||||
|
||||
|
||||
await self._save()
|
||||
logger.info(f"Reset all: {count} tokens updated")
|
||||
|
||||
async def reset_token(self, token_str: str) -> bool:
|
||||
"""
|
||||
重置单个 Token
|
||||
|
||||
|
||||
Args:
|
||||
token_str: Token 字符串
|
||||
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
raw_token = token_str.replace("sso=", "")
|
||||
|
||||
|
||||
for pool in self.pools.values():
|
||||
token = pool.get(raw_token)
|
||||
if token:
|
||||
@@ -394,7 +456,7 @@ class TokenManager:
|
||||
await self._save()
|
||||
logger.info(f"Token {raw_token[:10]}...: reset completed")
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"Token {raw_token[:10]}...: not found for reset")
|
||||
return False
|
||||
|
||||
@@ -405,14 +467,14 @@ class TokenManager:
|
||||
pool_stats = pool.get_stats()
|
||||
stats[name] = pool_stats.model_dump()
|
||||
return stats
|
||||
|
||||
|
||||
def get_pool_tokens(self, pool_name: str = "ssoBasic") -> List[TokenInfo]:
|
||||
"""
|
||||
获取指定池的所有 Token
|
||||
|
||||
|
||||
Args:
|
||||
pool_name: 池名称
|
||||
|
||||
|
||||
Returns:
|
||||
Token 列表
|
||||
"""
|
||||
@@ -420,72 +482,72 @@ class TokenManager:
|
||||
if not pool:
|
||||
return []
|
||||
return pool.list()
|
||||
|
||||
|
||||
async def refresh_cooling_tokens(self) -> Dict[str, int]:
|
||||
"""
|
||||
批量刷新 cooling 状态的 Token 配额
|
||||
|
||||
|
||||
Returns:
|
||||
{"checked": int, "refreshed": int, "recovered": int, "expired": int}
|
||||
"""
|
||||
from app.services.grok.usage import UsageService
|
||||
|
||||
|
||||
# 收集需要刷新的 token
|
||||
to_refresh: List[TokenInfo] = []
|
||||
for pool in self.pools.values():
|
||||
for token in pool:
|
||||
if token.need_refresh(REFRESH_INTERVAL_HOURS):
|
||||
to_refresh.append(token)
|
||||
|
||||
|
||||
if not to_refresh:
|
||||
logger.debug("Refresh check: no tokens need refresh")
|
||||
return {"checked": 0, "refreshed": 0, "recovered": 0, "expired": 0}
|
||||
|
||||
|
||||
logger.info(f"Refresh check: found {len(to_refresh)} cooling tokens to refresh")
|
||||
|
||||
|
||||
# 批量并发刷新
|
||||
semaphore = asyncio.Semaphore(REFRESH_CONCURRENCY)
|
||||
usage_service = UsageService()
|
||||
refreshed = 0
|
||||
recovered = 0
|
||||
expired = 0
|
||||
|
||||
|
||||
async def _refresh_one(token_info: TokenInfo) -> dict:
|
||||
"""刷新单个 token"""
|
||||
async with semaphore:
|
||||
token_str = token_info.token
|
||||
if token_str.startswith("sso="):
|
||||
token_str = token_str[4:]
|
||||
|
||||
|
||||
# 重试逻辑:最多 2 次重试
|
||||
for retry in range(3): # 0, 1, 2
|
||||
try:
|
||||
result = await usage_service.get(token_str)
|
||||
|
||||
|
||||
if result and "remainingTokens" in result:
|
||||
new_quota = result["remainingTokens"]
|
||||
old_quota = token_info.quota
|
||||
old_status = token_info.status
|
||||
|
||||
|
||||
token_info.update_quota(new_quota)
|
||||
token_info.mark_synced()
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Token {token_info.token[:10]}...: refreshed "
|
||||
f"{old_quota} -> {new_quota}, status: {old_status} -> {token_info.status}"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"recovered": new_quota > 0 and old_quota == 0,
|
||||
"expired": False
|
||||
"expired": False,
|
||||
}
|
||||
|
||||
|
||||
token_info.mark_synced()
|
||||
return {"recovered": False, "expired": False}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
|
||||
|
||||
# 检查是否为 401 错误
|
||||
if "401" in error_str or "Unauthorized" in error_str:
|
||||
if retry < 2:
|
||||
@@ -510,35 +572,35 @@ class TokenManager:
|
||||
)
|
||||
token_info.mark_synced()
|
||||
return {"recovered": False, "expired": False}
|
||||
|
||||
|
||||
token_info.mark_synced()
|
||||
return {"recovered": False, "expired": False}
|
||||
|
||||
|
||||
# 批量处理
|
||||
for i in range(0, len(to_refresh), REFRESH_BATCH_SIZE):
|
||||
batch = to_refresh[i:i + REFRESH_BATCH_SIZE]
|
||||
batch = to_refresh[i : i + REFRESH_BATCH_SIZE]
|
||||
results = await asyncio.gather(*[_refresh_one(t) for t in batch])
|
||||
refreshed += len(batch)
|
||||
recovered += sum(r["recovered"] for r in results)
|
||||
expired += sum(r["expired"] for r in results)
|
||||
|
||||
|
||||
# 批次间延迟
|
||||
if i + REFRESH_BATCH_SIZE < len(to_refresh):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
await self._save()
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Refresh completed: "
|
||||
f"checked={len(to_refresh)}, refreshed={refreshed}, "
|
||||
f"recovered={recovered}, expired={expired}"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"checked": len(to_refresh),
|
||||
"refreshed": refreshed,
|
||||
"recovered": recovered,
|
||||
"expired": expired
|
||||
"expired": expired,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<script src="/static/common/header.js?v=1"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/config/config.js?v=5"></script>
|
||||
<script src="/static/config/config.js?v=6"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -12,7 +12,10 @@ const NUMERIC_FIELDS = new Set([
|
||||
'usage_max_concurrent',
|
||||
'assets_delete_batch_size',
|
||||
'admin_assets_batch_size',
|
||||
'reload_interval_sec'
|
||||
'reload_interval_sec',
|
||||
'nsfw_max_concurrent',
|
||||
'admin_nsfw_batch_size',
|
||||
'nsfw_max_tokens'
|
||||
]);
|
||||
|
||||
const LOCALE_MAP = {
|
||||
@@ -57,7 +60,10 @@ const LOCALE_MAP = {
|
||||
"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。" }
|
||||
"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。" }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: #f3e8ff;
|
||||
color: #9333ea;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -317,3 +322,10 @@
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
}
|
||||
@@ -108,6 +108,15 @@
|
||||
data-filter="expired" onclick="filterByStatus('expired')">
|
||||
失效 <span class="badge badge-red" id="tab-count-expired">0</span>
|
||||
</button>
|
||||
<span class="tab-sep"></span>
|
||||
<button class="tab-item" role="tab" aria-selected="false"
|
||||
data-filter="nsfw" onclick="filterByStatus('nsfw')">
|
||||
已开 NSFW <span class="badge badge-purple" id="tab-count-nsfw">0</span>
|
||||
</button>
|
||||
<button class="tab-item" role="tab" aria-selected="false"
|
||||
data-filter="no-nsfw" onclick="filterByStatus('no-nsfw')">
|
||||
未开 NSFW <span class="badge badge-gray" id="tab-count-no-nsfw">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token Table -->
|
||||
|
||||
@@ -47,14 +47,15 @@ function processTokens(data) {
|
||||
tokens.forEach(t => {
|
||||
// Normalize
|
||||
const tObj = typeof t === 'string'
|
||||
? { token: t, status: 'active', quota: 0, note: '', use_count: 0 }
|
||||
? { token: t, status: 'active', quota: 0, note: '', use_count: 0, tags: [] }
|
||||
: {
|
||||
token: t.token,
|
||||
status: t.status || 'active',
|
||||
quota: t.quota || 0,
|
||||
note: t.note || '',
|
||||
fail_count: t.fail_count || 0,
|
||||
use_count: t.use_count || 0
|
||||
use_count: t.use_count || 0,
|
||||
tags: t.tags || []
|
||||
};
|
||||
flatTokens.push({ ...tObj, pool: pool, _selected: false });
|
||||
});
|
||||
@@ -150,14 +151,18 @@ function renderTable() {
|
||||
tdType.className = 'text-center';
|
||||
tdType.innerHTML = `<span class="badge badge-gray">${escapeHtml(item.pool)}</span>`;
|
||||
|
||||
// Status (Center)
|
||||
// Status (Center) - 显示状态和 nsfw 标签
|
||||
const tdStatus = document.createElement('td');
|
||||
let statusClass = 'badge-gray';
|
||||
if (item.status === 'active') statusClass = 'badge-green';
|
||||
else if (item.status === 'cooling') statusClass = 'badge-orange';
|
||||
else statusClass = 'badge-red';
|
||||
tdStatus.className = 'text-center';
|
||||
tdStatus.innerHTML = `<span class="badge ${statusClass}">${item.status}</span>`;
|
||||
let statusHtml = `<span class="badge ${statusClass}">${item.status}</span>`;
|
||||
if (item.tags && item.tags.includes('nsfw')) {
|
||||
statusHtml += ` <span class="badge badge-purple">nsfw</span>`;
|
||||
}
|
||||
tdStatus.innerHTML = statusHtml;
|
||||
|
||||
// Quota (Center)
|
||||
const tdQuota = document.createElement('td');
|
||||
@@ -766,6 +771,8 @@ function getFilteredTokens() {
|
||||
if (currentFilter === 'active') return t.status === 'active';
|
||||
if (currentFilter === 'cooling') return t.status === 'cooling';
|
||||
if (currentFilter === 'expired') return t.status !== 'active' && t.status !== 'cooling';
|
||||
if (currentFilter === 'nsfw') return t.tags && t.tags.includes('nsfw');
|
||||
if (currentFilter === 'no-nsfw') return !t.tags || !t.tags.includes('nsfw');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -775,7 +782,9 @@ function updateTabCounts() {
|
||||
all: flatTokens.length,
|
||||
active: flatTokens.filter(t => t.status === 'active').length,
|
||||
cooling: flatTokens.filter(t => t.status === 'cooling').length,
|
||||
expired: flatTokens.filter(t => t.status !== 'active' && t.status !== 'cooling').length
|
||||
expired: flatTokens.filter(t => t.status !== 'active' && t.status !== 'cooling').length,
|
||||
nsfw: flatTokens.filter(t => t.tags && t.tags.includes('nsfw')).length,
|
||||
'no-nsfw': flatTokens.filter(t => !t.tags || !t.tags.includes('nsfw')).length
|
||||
};
|
||||
|
||||
Object.entries(counts).forEach(([key, count]) => {
|
||||
@@ -825,10 +834,13 @@ async function batchEnableNSFW() {
|
||||
|
||||
if (res.ok && data.status === 'success') {
|
||||
const { ok: okCount, fail } = data.summary;
|
||||
showToast(
|
||||
`NSFW 开启完成:成功 ${okCount},失败 ${fail}`,
|
||||
fail > 0 ? 'warning' : 'success'
|
||||
);
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user