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')}},