diff --git a/src/api/admin.py b/src/api/admin.py index 920f9d9..aec6364 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -62,6 +62,7 @@ class AddTokenRequest(BaseModel): token: str # Access Token (AT) st: Optional[str] = None # Session Token (optional, for storage) rt: Optional[str] = None # Refresh Token (optional, for storage) + client_id: Optional[str] = None # Client ID (optional) remark: Optional[str] = None image_enabled: bool = True # Enable image generation video_enabled: bool = True # Enable video generation @@ -81,6 +82,7 @@ class UpdateTokenRequest(BaseModel): token: Optional[str] = None # Access Token st: Optional[str] = None rt: Optional[str] = None + client_id: Optional[str] = None # Client ID remark: Optional[str] = None image_enabled: Optional[bool] = None # Enable image generation video_enabled: Optional[bool] = None # Enable video generation @@ -169,6 +171,7 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]: "token": token.token, # 完整的Access Token "st": token.st, # 完整的Session Token "rt": token.rt, # 完整的Refresh Token + "client_id": token.client_id, # Client ID "email": token.email, "name": token.name, "remark": token.remark, @@ -210,6 +213,7 @@ async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_ token_value=request.token, st=request.st, rt=request.rt, + client_id=request.client_id, remark=request.remark, update_if_exists=False, image_enabled=request.image_enabled, @@ -412,6 +416,7 @@ async def update_token( token=request.token, st=request.st, rt=request.rt, + client_id=request.client_id, remark=request.remark, image_enabled=request.image_enabled, video_enabled=request.video_enabled, diff --git a/src/core/database.py b/src/core/database.py index 8778e01..0e114a9 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -194,6 +194,7 @@ class Database: ("video_enabled", "BOOLEAN DEFAULT 1"), ("image_concurrency", "INTEGER DEFAULT -1"), ("video_concurrency", "INTEGER DEFAULT -1"), + ("client_id", "TEXT"), ] for col_name, col_type in columns_to_add: @@ -269,6 +270,7 @@ class Database: name TEXT NOT NULL, st TEXT, rt TEXT, + client_id TEXT, remark TEXT, expiry_time TIMESTAMP, is_active BOOLEAN DEFAULT 1, @@ -561,12 +563,12 @@ class Database: """Add a new token""" async with aiosqlite.connect(self.db_path) as db: cursor = await db.execute(""" - INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active, + INSERT INTO tokens (token, email, username, name, st, rt, client_id, 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, image_enabled, video_enabled, image_concurrency, video_concurrency) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (token.token, token.email, "", token.name, token.st, token.rt, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (token.token, token.email, "", token.name, token.st, token.rt, token.client_id, token.remark, token.expiry_time, token.is_active, token.plan_type, token.plan_title, token.subscription_end, token.sora2_supported, token.sora2_invite_code, @@ -701,6 +703,7 @@ class Database: token: Optional[str] = None, st: Optional[str] = None, rt: Optional[str] = None, + client_id: Optional[str] = None, remark: Optional[str] = None, expiry_time: Optional[datetime] = None, plan_type: Optional[str] = None, @@ -710,7 +713,7 @@ class Database: video_enabled: Optional[bool] = None, image_concurrency: Optional[int] = None, video_concurrency: Optional[int] = None): - """Update token (AT, ST, RT, remark, expiry_time, subscription info, image_enabled, video_enabled)""" + """Update token (AT, ST, RT, client_id, remark, expiry_time, subscription info, image_enabled, video_enabled)""" async with aiosqlite.connect(self.db_path) as db: # Build dynamic update query updates = [] @@ -728,6 +731,10 @@ class Database: updates.append("rt = ?") params.append(rt) + if client_id is not None: + updates.append("client_id = ?") + params.append(client_id) + if remark is not None: updates.append("remark = ?") params.append(remark) diff --git a/src/core/models.py b/src/core/models.py index 806e09e..17a73da 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -11,6 +11,7 @@ class Token(BaseModel): name: Optional[str] = "" st: Optional[str] = None rt: Optional[str] = None + client_id: Optional[str] = None remark: Optional[str] = None expiry_time: Optional[datetime] = None is_active: bool = True diff --git a/src/services/token_manager.py b/src/services/token_manager.py index 7569aa4..e35c830 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -513,9 +513,18 @@ class TokenManager: debug_logger.log_info(f"[ST_TO_AT] 🔴 异常: {str(e)}") raise - async def rt_to_at(self, refresh_token: str) -> dict: - """Convert Refresh Token to Access Token""" + async def rt_to_at(self, refresh_token: str, client_id: Optional[str] = None) -> dict: + """Convert Refresh Token to Access Token + + Args: + refresh_token: Refresh Token + client_id: Client ID (optional, uses default if not provided) + """ + # Use provided client_id or default + effective_client_id = client_id or "app_LlGpXReQgckcGGUo2JrYvtJK" + debug_logger.log_info(f"[RT_TO_AT] 开始转换 Refresh Token 为 Access Token...") + debug_logger.log_info(f"[RT_TO_AT] 使用 Client ID: {effective_client_id[:20]}...") proxy_url = await self.proxy_manager.get_proxy_url() async with AsyncSession() as session: @@ -527,7 +536,7 @@ class TokenManager: kwargs = { "headers": headers, "json": { - "client_id": "app_LlGpXReQgckcGGUo2JrYvtJK", + "client_id": effective_client_id, "grant_type": "refresh_token", "redirect_uri": "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback", "refresh_token": refresh_token @@ -600,6 +609,7 @@ class TokenManager: async def add_token(self, token_value: str, st: Optional[str] = None, rt: Optional[str] = None, + client_id: Optional[str] = None, remark: Optional[str] = None, update_if_exists: bool = False, image_enabled: bool = True, @@ -612,6 +622,7 @@ class TokenManager: token_value: Access Token st: Session Token (optional) rt: Refresh Token (optional) + client_id: Client ID (optional) remark: Remark (optional) update_if_exists: If True, update existing token instead of raising error image_enabled: Enable image generation (default: True) @@ -747,6 +758,7 @@ class TokenManager: name=name, st=st, rt=rt, + client_id=client_id, remark=remark, expiry_time=expiry_time, is_active=True, @@ -831,12 +843,13 @@ class TokenManager: token: Optional[str] = None, st: Optional[str] = None, rt: Optional[str] = None, + client_id: Optional[str] = None, remark: Optional[str] = None, image_enabled: Optional[bool] = None, video_enabled: Optional[bool] = None, image_concurrency: Optional[int] = None, video_concurrency: Optional[int] = None): - """Update token (AT, ST, RT, remark, image_enabled, video_enabled, concurrency limits)""" + """Update token (AT, ST, RT, client_id, remark, image_enabled, video_enabled, concurrency limits)""" # If token (AT) is updated, decode JWT to get new expiry time expiry_time = None if token: @@ -846,7 +859,7 @@ 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, client_id=client_id, remark=remark, expiry_time=expiry_time, image_enabled=image_enabled, video_enabled=video_enabled, image_concurrency=image_concurrency, video_concurrency=video_concurrency) @@ -1063,7 +1076,7 @@ class TokenManager: if not new_at and token_data.rt: try: debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 RT 刷新...") - result = await self.rt_to_at(token_data.rt) + result = await self.rt_to_at(token_data.rt, client_id=token_data.client_id) new_at = result.get("access_token") new_rt = result.get("refresh_token", token_data.rt) # RT might be updated refresh_method = "RT" diff --git a/static/manage.html b/static/manage.html index ffff66e..9369ed3 100644 --- a/static/manage.html +++ b/static/manage.html @@ -132,6 +132,7 @@ 邮箱 状态 + Client ID 过期时间 账户类型 Sora2 @@ -420,6 +421,13 @@ + +
+ + +

用于 RT 刷新,留空使用默认 Client ID

+
+
@@ -509,6 +517,13 @@
+ +
+ + +

用于 RT 刷新,留空使用默认 Client ID

+
+
@@ -633,18 +648,19 @@ 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=>{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('')}, + formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`${short}`}, + 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?'活跃':'禁用'}${formatClientId(t.client_id)}${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='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('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;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('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;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').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(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;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,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});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='';$('addTokenClientId').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('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||'';$('editTokenClientId').value=token.client_id||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')}, + closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')}, + submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;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,client_id:clientId||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});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(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);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,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});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(),clientId=$('addTokenClientId').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);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,client_id:clientId||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});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')}},