From 7269e3fa7986f128821642d1a4e5e1eb1b51021d Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sun, 9 Nov 2025 09:57:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BAtoken=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E3=80=81=E8=A7=86=E9=A2=91=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.warp.yml | 2 +- docker-compose.yml | 2 +- src/api/admin.py | 19 +++++++++++--- src/core/database.py | 33 ++++++++++++++++++++---- src/core/models.py | 3 +++ src/services/load_balancer.py | 14 +++++++--- src/services/token_manager.py | 19 ++++++++++---- static/manage.html | 48 ++++++++++++++++++++++++++++++----- 8 files changed, 114 insertions(+), 26 deletions(-) diff --git a/docker-compose.warp.yml b/docker-compose.warp.yml index d9fc09d..8495d27 100644 --- a/docker-compose.warp.yml +++ b/docker-compose.warp.yml @@ -2,7 +2,7 @@ version: '3.8' services: sora2api: - image: thesmallhancat/sora2api:2.0 + image: thesmallhancat/sora2api:3.0 container_name: sora2api ports: - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 49455cf..7980440 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: sora2api: - image: thesmallhancat/sora2api:2.0 + image: thesmallhancat/sora2api:3.0 container_name: sora2api ports: - "8000:8000" diff --git a/src/api/admin.py b/src/api/admin.py index e603dbf..500dcfb 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -62,6 +62,8 @@ class AddTokenRequest(BaseModel): st: Optional[str] = None # Session Token (optional, for storage) rt: Optional[str] = None # Refresh Token (optional, for storage) remark: Optional[str] = None + image_enabled: bool = True # Enable image generation + video_enabled: bool = True # Enable video generation class ST2ATRequest(BaseModel): st: str # Session Token @@ -77,6 +79,8 @@ class UpdateTokenRequest(BaseModel): st: Optional[str] = None rt: Optional[str] = None remark: Optional[str] = None + image_enabled: Optional[bool] = None # Enable image generation + video_enabled: Optional[bool] = None # Enable video generation class UpdateAdminConfigRequest(BaseModel): error_ban_threshold: int @@ -171,7 +175,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]: "sora2_redeemed_count": token.sora2_redeemed_count, "sora2_total_count": token.sora2_total_count, "sora2_remaining_count": token.sora2_remaining_count, - "sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None + "sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None, + # 功能开关 + "image_enabled": token.image_enabled, + "video_enabled": token.video_enabled }) return result @@ -185,7 +192,9 @@ async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_ st=request.st, rt=request.rt, remark=request.remark, - update_if_exists=False + update_if_exists=False, + image_enabled=request.image_enabled, + video_enabled=request.video_enabled ) return {"success": True, "message": "Token 添加成功", "token_id": new_token.id} except ValueError as e: @@ -302,14 +311,16 @@ async def update_token( request: UpdateTokenRequest, token: str = Depends(verify_admin_token) ): - """Update token (AT, ST, RT, remark)""" + """Update token (AT, ST, RT, remark, image_enabled, video_enabled)""" try: await token_manager.update_token( token_id=token_id, token=request.token, st=request.st, rt=request.rt, - remark=request.remark + remark=request.remark, + image_enabled=request.image_enabled, + video_enabled=request.video_enabled ) return {"success": True, "message": "Token updated"} except Exception as e: diff --git a/src/core/database.py b/src/core/database.py index c5b2a07..98b3183 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -100,6 +100,17 @@ class Database: except: pass # Column already exists + # Add image_enabled and video_enabled columns if they don't exist (migration) + try: + await db.execute("ALTER TABLE tokens ADD COLUMN image_enabled BOOLEAN DEFAULT 1") + except: + pass # Column already exists + + try: + await db.execute("ALTER TABLE tokens ADD COLUMN video_enabled BOOLEAN DEFAULT 1") + except: + pass # Column already exists + # Token stats table await db.execute(""" CREATE TABLE IF NOT EXISTS token_stats ( @@ -286,14 +297,16 @@ class Database: cursor = await db.execute(""" INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active, plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code, - sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until, + image_enabled, video_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (token.token, token.email, "", token.name, token.st, token.rt, token.remark, token.expiry_time, token.is_active, token.plan_type, token.plan_title, token.subscription_end, token.sora2_supported, token.sora2_invite_code, token.sora2_redeemed_count, token.sora2_total_count, - token.sora2_remaining_count, token.sora2_cooldown_until)) + token.sora2_remaining_count, token.sora2_cooldown_until, + token.image_enabled, token.video_enabled)) await db.commit() token_id = cursor.lastrowid @@ -415,8 +428,10 @@ class Database: expiry_time: Optional[datetime] = None, plan_type: Optional[str] = None, plan_title: Optional[str] = None, - subscription_end: Optional[datetime] = None): - """Update token (AT, ST, RT, remark, expiry_time, subscription info)""" + subscription_end: Optional[datetime] = None, + image_enabled: Optional[bool] = None, + video_enabled: Optional[bool] = None): + """Update token (AT, ST, RT, remark, expiry_time, subscription info, image_enabled, video_enabled)""" async with aiosqlite.connect(self.db_path) as db: # Build dynamic update query updates = [] @@ -454,6 +469,14 @@ class Database: updates.append("subscription_end = ?") params.append(subscription_end) + if image_enabled is not None: + updates.append("image_enabled = ?") + params.append(image_enabled) + + if video_enabled is not None: + updates.append("video_enabled = ?") + params.append(video_enabled) + if updates: params.append(token_id) query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?" diff --git a/src/core/models.py b/src/core/models.py index 7d49d0f..e14da84 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -30,6 +30,9 @@ class Token(BaseModel): # Sora2 剩余次数 sora2_remaining_count: int = 0 # Sora2剩余可用次数 sora2_cooldown_until: Optional[datetime] = None # Sora2冷却时间 + # 功能开关 + image_enabled: bool = True # 是否启用图片生成 + video_enabled: bool = True # 是否启用视频生成 class TokenStats(BaseModel): """Token statistics""" diff --git a/src/services/load_balancer.py b/src/services/load_balancer.py index 6976bcd..bb76d2f 100644 --- a/src/services/load_balancer.py +++ b/src/services/load_balancer.py @@ -19,8 +19,8 @@ class LoadBalancer: Select a token using random load balancing Args: - for_image_generation: If True, only select tokens that are not locked for image generation - for_video_generation: If True, filter out tokens with Sora2 quota exhausted (sora2_cooldown_until not expired) and tokens that don't support Sora2 + for_image_generation: If True, only select tokens that are not locked for image generation and have image_enabled=True + for_video_generation: If True, filter out tokens with Sora2 quota exhausted (sora2_cooldown_until not expired), tokens that don't support Sora2, and tokens with video_enabled=False Returns: Selected token or None if no available tokens @@ -35,6 +35,10 @@ class LoadBalancer: from datetime import datetime available_tokens = [] for token in active_tokens: + # Skip tokens that don't have video enabled + if not token.video_enabled: + continue + # Skip tokens that don't support Sora2 if not token.sora2_supported: continue @@ -57,10 +61,14 @@ class LoadBalancer: active_tokens = available_tokens - # If for image generation, filter out locked tokens + # If for image generation, filter out locked tokens and tokens without image enabled if for_image_generation: available_tokens = [] for token in active_tokens: + # Skip tokens that don't have image enabled + if not token.image_enabled: + continue + if not await self.token_lock.is_locked(token.id): available_tokens.append(token) diff --git a/src/services/token_manager.py b/src/services/token_manager.py index de7bacf..cd5e3a9 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -494,7 +494,9 @@ class TokenManager: st: Optional[str] = None, rt: Optional[str] = None, remark: Optional[str] = None, - update_if_exists: bool = False) -> Token: + update_if_exists: bool = False, + image_enabled: bool = True, + video_enabled: bool = True) -> Token: """Add a new Access Token to database Args: @@ -503,6 +505,8 @@ class TokenManager: rt: Refresh Token (optional) remark: Remark (optional) update_if_exists: If True, update existing token instead of raising error + image_enabled: Enable image generation (default: True) + video_enabled: Enable video generation (default: True) Returns: Token object @@ -634,7 +638,9 @@ class TokenManager: sora2_invite_code=sora2_invite_code, sora2_redeemed_count=sora2_redeemed_count, sora2_total_count=sora2_total_count, - sora2_remaining_count=sora2_remaining_count + sora2_remaining_count=sora2_remaining_count, + image_enabled=image_enabled, + video_enabled=video_enabled ) # Save to database @@ -704,8 +710,10 @@ class TokenManager: token: Optional[str] = None, st: Optional[str] = None, rt: Optional[str] = None, - remark: Optional[str] = None): - """Update token (AT, ST, RT, remark)""" + remark: Optional[str] = None, + image_enabled: Optional[bool] = None, + video_enabled: Optional[bool] = None): + """Update token (AT, ST, RT, remark, image_enabled, video_enabled)""" # If token (AT) is updated, decode JWT to get new expiry time expiry_time = None if token: @@ -715,7 +723,8 @@ class TokenManager: except Exception: pass # If JWT decode fails, keep expiry_time as None - await self.db.update_token(token_id, token=token, st=st, rt=rt, remark=remark, expiry_time=expiry_time) + await self.db.update_token(token_id, token=token, st=st, rt=rt, remark=remark, expiry_time=expiry_time, + image_enabled=image_enabled, video_enabled=video_enabled) async def get_active_tokens(self) -> List[Token]: """Get all active tokens (not cooled down)""" diff --git a/static/manage.html b/static/manage.html index 1ba4183..5595355 100644 --- a/static/manage.html +++ b/static/manage.html @@ -273,7 +273,7 @@
-

例如: http://192.168.1.100:8080

+

部署自定义解析服务器

@@ -393,6 +393,23 @@
+ + +
+ +
+ +
+
+ +
+
@@ -459,6 +476,23 @@
+ + +
+ +
+ +
+
+ +
+
@@ -518,18 +552,18 @@ formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`
支持${remaining}/${t.sora2_total_count}
`}else if(t.sora2_supported===false){return`不支持`}else{return'-'}}, formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`${formatPlanType(t.plan_type)}`}, formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`${remaining}`}else{return'-'}}, - renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>`${t.email}${t.is_active?'活跃':'禁用'}${formatExpiry(t.expiry_time)}${formatPlanTypeWithTooltip(t)}${formatSora2(t)}${formatSora2Remaining(t)}${t.image_count||0}${t.video_count||0}${t.error_count||0}${t.remark||'-'}`).join('')}, + renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=(t.video_enabled&&t.sora2_supported)?`${t.video_count||0}`:'-';return`${t.email}${t.is_active?'活跃':'禁用'}${formatExpiry(t.expiry_time)}${formatPlanTypeWithTooltip(t)}${formatSora2(t)}${formatSora2Remaining(t)}${imageDisplay}${videoDisplay}${t.error_count||0}${t.remark||'-'}`}).join('')}, refreshTokens=async()=>{await loadTokens();await loadStats()}, openAddModal=()=>$('addModal').classList.remove('hidden'), - closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addRTRefreshHint').classList.add('hidden')}, - openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenRemark').value=token.remark||'';$('editModal').classList.remove('hidden')}, - closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenRemark').value='';$('editRTRefreshHint').classList.add('hidden')}, - submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),remark=$('editTokenRemark').value.trim();if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}}, + closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addRTRefreshHint').classList.add('hidden')}, + openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editModal').classList.remove('hidden')}, + closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editRTRefreshHint').classList.add('hidden')}, + submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}}, convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, - submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim();if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}}, + submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked;if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}}, testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}}, toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}}, toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},