From 1cf80a2489a43821c311c136f0d08f514ebfed22 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Fri, 9 Jan 2026 19:40:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Etoken=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81(=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF/AT/ST/RT)=E5=8F=8A=E8=B4=A6=E5=8F=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=B5=8B=E8=AF=95=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin.py | 73 ++++++++++-- src/services/token_manager.py | 218 ++++++++++++++++++++-------------- static/manage.html | 39 +++++- 3 files changed, 230 insertions(+), 100 deletions(-) diff --git a/src/api/admin.py b/src/api/admin.py index ef923d1..dba1d7c 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -95,8 +95,8 @@ class UpdateTokenRequest(BaseModel): video_concurrency: Optional[int] = None # Video concurrency limit class ImportTokenItem(BaseModel): - email: str # Email (primary key) - access_token: str # Access Token (AT) + email: str # Email (primary key, required) + access_token: Optional[str] = None # Access Token (AT, optional for st/rt modes) session_token: Optional[str] = None # Session Token (ST) refresh_token: Optional[str] = None # Refresh Token (RT) client_id: Optional[str] = None # Client ID (optional, for compatibility) @@ -110,6 +110,7 @@ class ImportTokenItem(BaseModel): class ImportTokensRequest(BaseModel): tokens: List[ImportTokenItem] + mode: str = "at" # Import mode: offline/at/st/rt class UpdateAdminConfigRequest(BaseModel): error_ban_threshold: int @@ -349,7 +350,8 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)): @router.post("/api/tokens/import") async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)): - """Import tokens in append mode (update if exists, add if not)""" + """Import tokens with different modes: offline/at/st/rt""" + mode = request.mode # offline/at/st/rt added_count = 0 updated_count = 0 failed_count = 0 @@ -357,14 +359,64 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif for import_item in request.tokens: try: - # Check if token with this email already exists + # Step 1: Get or convert access_token based on mode + access_token = None + skip_status = False + + if mode == "offline": + # Offline mode: use provided AT, skip status update + if not import_item.access_token: + raise ValueError("离线导入模式需要提供 access_token") + access_token = import_item.access_token + skip_status = True + + elif mode == "at": + # AT mode: use provided AT, update status (current logic) + if not import_item.access_token: + raise ValueError("AT导入模式需要提供 access_token") + access_token = import_item.access_token + skip_status = False + + elif mode == "st": + # ST mode: convert ST to AT, update status + if not import_item.session_token: + raise ValueError("ST导入模式需要提供 session_token") + # Convert ST to AT + st_result = await token_manager.st_to_at(import_item.session_token) + access_token = st_result["access_token"] + # Update email if API returned it + if "email" in st_result and st_result["email"]: + import_item.email = st_result["email"] + skip_status = False + + elif mode == "rt": + # RT mode: convert RT to AT, update status + if not import_item.refresh_token: + raise ValueError("RT导入模式需要提供 refresh_token") + # Convert RT to AT + rt_result = await token_manager.rt_to_at( + import_item.refresh_token, + client_id=import_item.client_id + ) + access_token = rt_result["access_token"] + # Update RT if API returned new one + if "refresh_token" in rt_result and rt_result["refresh_token"]: + import_item.refresh_token = rt_result["refresh_token"] + # Update email if API returned it + if "email" in rt_result and rt_result["email"]: + import_item.email = rt_result["email"] + skip_status = False + else: + raise ValueError(f"不支持的导入模式: {mode}") + + # Step 2: Check if token with this email already exists existing_token = await db.get_token_by_email(import_item.email) if existing_token: # Update existing token await token_manager.update_token( token_id=existing_token.id, - token=import_item.access_token, + token=access_token, st=import_item.session_token, rt=import_item.refresh_token, client_id=import_item.client_id, @@ -373,7 +425,8 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif image_enabled=import_item.image_enabled, video_enabled=import_item.video_enabled, image_concurrency=import_item.image_concurrency, - video_concurrency=import_item.video_concurrency + video_concurrency=import_item.video_concurrency, + skip_status_update=skip_status ) # Update active status await token_manager.update_token_status(existing_token.id, import_item.is_active) @@ -393,7 +446,7 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif else: # Add new token new_token = await token_manager.add_token( - token_value=import_item.access_token, + token_value=access_token, st=import_item.session_token, rt=import_item.refresh_token, client_id=import_item.client_id, @@ -403,7 +456,9 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif image_enabled=import_item.image_enabled, video_enabled=import_item.video_enabled, image_concurrency=import_item.image_concurrency, - video_concurrency=import_item.video_concurrency + video_concurrency=import_item.video_concurrency, + skip_status_update=skip_status, + email=import_item.email # Pass email for offline mode ) # Set active status if not import_item.is_active: @@ -432,7 +487,7 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif return { "success": True, - "message": f"Import completed: {added_count} added, {updated_count} updated, {failed_count} failed", + "message": f"Import completed ({mode} mode): {added_count} added, {updated_count} updated, {failed_count} failed", "added": added_count, "updated": updated_count, "failed": failed_count, diff --git a/src/services/token_manager.py b/src/services/token_manager.py index 84516b6..39f281e 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -658,7 +658,9 @@ class TokenManager: image_enabled: bool = True, video_enabled: bool = True, image_concurrency: int = -1, - video_concurrency: int = -1) -> Token: + video_concurrency: int = -1, + skip_status_update: bool = False, + email: Optional[str] = None) -> Token: """Add a new Access Token to database Args: @@ -699,101 +701,112 @@ class TokenManager: if "https://api.openai.com/profile" in decoded: jwt_email = decoded["https://api.openai.com/profile"].get("email") - # Get user info from Sora API - try: - user_info = await self.get_user_info(token_value, proxy_url=proxy_url) - email = user_info.get("email", jwt_email or "") - name = user_info.get("name") or "" - except Exception as e: - # If API call fails, use JWT data - email = jwt_email or "" - name = email.split("@")[0] if email else "" - - # Get subscription info from Sora API + # Initialize variables + name = "" plan_type = None plan_title = None subscription_end = None - try: - sub_info = await self.get_subscription_info(token_value, proxy_url=proxy_url) - plan_type = sub_info.get("plan_type") - plan_title = sub_info.get("plan_title") - # Parse subscription end time - if sub_info.get("subscription_end"): - from dateutil import parser - subscription_end = parser.parse(sub_info["subscription_end"]) - except Exception as e: - error_msg = str(e) - # Re-raise if it's a critical error (token expired) - if "Token已过期" in error_msg: - raise - # If API call fails, subscription info will be None - print(f"Failed to get subscription info: {e}") - - # Get Sora2 invite code sora2_supported = None sora2_invite_code = None - sora2_redeemed_count = 0 - sora2_total_count = 0 - sora2_remaining_count = 0 - try: - sora2_info = await self.get_sora2_invite_code(token_value, proxy_url=proxy_url) - sora2_supported = sora2_info.get("supported", False) - sora2_invite_code = sora2_info.get("invite_code") - sora2_redeemed_count = sora2_info.get("redeemed_count", 0) - sora2_total_count = sora2_info.get("total_count", 0) + sora2_redeemed_count = -1 + sora2_total_count = -1 + sora2_remaining_count = -1 - # If Sora2 is supported, get remaining count - if sora2_supported: - try: - remaining_info = await self.get_sora2_remaining_count(token_value, proxy_url=proxy_url) - if remaining_info.get("success"): - sora2_remaining_count = remaining_info.get("remaining_count", 0) - print(f"✅ Sora2剩余次数: {sora2_remaining_count}") - except Exception as e: - print(f"Failed to get Sora2 remaining count: {e}") - except Exception as e: - error_msg = str(e) - # Re-raise if it's a critical error (unsupported country) - if "Sora在您的国家/地区不可用" in error_msg: - raise - # If API call fails, Sora2 info will be None - print(f"Failed to get Sora2 info: {e}") + if skip_status_update: + # Offline mode: use provided email or JWT email, skip API calls + email = email or jwt_email or "" + name = email.split("@")[0] if email else "" + else: + # Normal mode: get user info from Sora API + try: + user_info = await self.get_user_info(token_value, proxy_url=proxy_url) + email = user_info.get("email", jwt_email or "") + name = user_info.get("name") or "" + except Exception as e: + # If API call fails, use JWT data + email = jwt_email or "" + name = email.split("@")[0] if email else "" - # Check and set username if needed - try: - # Get fresh user info to check username - user_info = await self.get_user_info(token_value, proxy_url=proxy_url) - username = user_info.get("username") + # Get subscription info from Sora API + try: + sub_info = await self.get_subscription_info(token_value, proxy_url=proxy_url) + plan_type = sub_info.get("plan_type") + plan_title = sub_info.get("plan_title") + # Parse subscription end time + if sub_info.get("subscription_end"): + from dateutil import parser + subscription_end = parser.parse(sub_info["subscription_end"]) + except Exception as e: + error_msg = str(e) + # Re-raise if it's a critical error (token expired) + if "Token已过期" in error_msg: + raise + # If API call fails, subscription info will be None + print(f"Failed to get subscription info: {e}") - # If username is null, need to set one - if username is None: - print(f"⚠️ 检测到用户名为null,需要设置用户名") + # Get Sora2 invite code + sora2_redeemed_count = 0 + sora2_total_count = 0 + sora2_remaining_count = 0 + try: + sora2_info = await self.get_sora2_invite_code(token_value, proxy_url=proxy_url) + sora2_supported = sora2_info.get("supported", False) + sora2_invite_code = sora2_info.get("invite_code") + sora2_redeemed_count = sora2_info.get("redeemed_count", 0) + sora2_total_count = sora2_info.get("total_count", 0) - # Generate random username - max_attempts = 5 - for attempt in range(max_attempts): - generated_username = self._generate_random_username() - print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}") + # If Sora2 is supported, get remaining count + if sora2_supported: + try: + remaining_info = await self.get_sora2_remaining_count(token_value, proxy_url=proxy_url) + if remaining_info.get("success"): + sora2_remaining_count = remaining_info.get("remaining_count", 0) + print(f"✅ Sora2剩余次数: {sora2_remaining_count}") + except Exception as e: + print(f"Failed to get Sora2 remaining count: {e}") + except Exception as e: + error_msg = str(e) + # Re-raise if it's a critical error (unsupported country) + if "Sora在您的国家/地区不可用" in error_msg: + raise + # If API call fails, Sora2 info will be None + print(f"Failed to get Sora2 info: {e}") - # Check if username is available - if await self.check_username_available(token_value, generated_username): - # Set the username - try: - await self.set_username(token_value, generated_username) - print(f"✅ 用户名设置成功: {generated_username}") - break - except Exception as e: - print(f"❌ 用户名设置失败: {e}") + # Check and set username if needed + try: + # Get fresh user info to check username + user_info = await self.get_user_info(token_value, proxy_url=proxy_url) + username = user_info.get("username") + + # If username is null, need to set one + if username is None: + print(f"⚠️ 检测到用户名为null,需要设置用户名") + + # Generate random username + max_attempts = 5 + for attempt in range(max_attempts): + generated_username = self._generate_random_username() + print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}") + + # Check if username is available + if await self.check_username_available(token_value, generated_username): + # Set the username + try: + await self.set_username(token_value, generated_username) + print(f"✅ 用户名设置成功: {generated_username}") + break + except Exception as e: + print(f"❌ 用户名设置失败: {e}") + if attempt == max_attempts - 1: + print(f"⚠️ 达到最大尝试次数,跳过用户名设置") + else: + print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个") if attempt == max_attempts - 1: print(f"⚠️ 达到最大尝试次数,跳过用户名设置") - else: - print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个") - if attempt == max_attempts - 1: - print(f"⚠️ 达到最大尝试次数,跳过用户名设置") - else: - print(f"✅ 用户名已设置: {username}") - except Exception as e: - print(f"⚠️ 用户名检查/设置过程中出错: {e}") + else: + print(f"✅ 用户名已设置: {username}") + except Exception as e: + print(f"⚠️ 用户名检查/设置过程中出错: {e}") # Create token object token = Token( @@ -894,7 +907,8 @@ class TokenManager: image_enabled: Optional[bool] = None, video_enabled: Optional[bool] = None, image_concurrency: Optional[int] = None, - video_concurrency: Optional[int] = None): + video_concurrency: Optional[int] = None, + skip_status_update: bool = False): """Update token (AT, ST, RT, client_id, proxy_url, remark, image_enabled, video_enabled, concurrency limits)""" # If token (AT) is updated, decode JWT to get new expiry time expiry_time = None @@ -909,8 +923,8 @@ class TokenManager: image_enabled=image_enabled, video_enabled=video_enabled, image_concurrency=image_concurrency, video_concurrency=video_concurrency) - # If token (AT) is updated, test it and clear expired flag if valid - if token: + # If token (AT) is updated and not in offline mode, test it and clear expired flag if valid + if token and not skip_status_update: try: test_result = await self.test_token(token_id) if test_result.get("valid"): @@ -945,7 +959,7 @@ class TokenManager: await self.db.update_token_status(token_id, False) async def test_token(self, token_id: int) -> dict: - """Test if a token is valid by calling Sora API and refresh Sora2 info""" + """Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)""" # Get token from database token_data = await self.db.get_token(token_id) if not token_data: @@ -955,6 +969,21 @@ class TokenManager: # Try to get user info from Sora API user_info = await self.get_user_info(token_data.token, token_id) + # Get subscription info from Sora API + plan_type = None + plan_title = None + subscription_end = None + try: + sub_info = await self.get_subscription_info(token_data.token, token_id) + plan_type = sub_info.get("plan_type") + plan_title = sub_info.get("plan_title") + # Parse subscription end time + if sub_info.get("subscription_end"): + from dateutil import parser + subscription_end = parser.parse(sub_info["subscription_end"]) + except Exception as e: + print(f"Failed to get subscription info: {e}") + # Refresh Sora2 invite code and counts sora2_info = await self.get_sora2_invite_code(token_data.token, token_id) sora2_supported = sora2_info.get("supported", False) @@ -972,6 +1001,14 @@ class TokenManager: except Exception as e: print(f"Failed to get Sora2 remaining count: {e}") + # Update token subscription info in database + await self.db.update_token( + token_id, + plan_type=plan_type, + plan_title=plan_title, + subscription_end=subscription_end + ) + # Update token Sora2 info in database await self.db.update_token_sora2( token_id, @@ -987,9 +1024,12 @@ class TokenManager: return { "valid": True, - "message": "Token is valid", + "message": "Token is valid and account info updated", "email": user_info.get("email"), "username": user_info.get("username"), + "plan_type": plan_type, + "plan_title": plan_title, + "subscription_end": subscription_end.isoformat() if subscription_end else None, "sora2_supported": sora2_supported, "sora2_invite_code": sora2_invite_code, "sora2_redeemed_count": sora2_redeemed_count, diff --git a/static/manage.html b/static/manage.html index d8ad62b..4d9b4a0 100644 --- a/static/manage.html +++ b/static/manage.html @@ -677,6 +677,40 @@

选择导出的 Token JSON 文件进行导入

+
+ + +

使用AT更新账号状态(订阅信息、Sora2次数等)

+
+
+

📋 导入模式说明

+
+
+ AT导入: + 完整更新所有账号信息(推荐) +
+
+ 离线导入: + 直接插入数据库,快速导入不调用API,账号信息需要单独获取 - +
+
+ ST导入: + 适用于只有ST没有AT,自动转换为AT +
+
+ RT导入: + 适用于只有RT没有AT,自动转换为AT +
+
+

+ 💡 提示:离线导入后可使用"测试"按钮更新账号信息,功能不稳定有bug问猫猫 +

+

说明:如果邮箱存在则会覆盖更新,不存在则会新增 @@ -754,8 +788,9 @@ openImportProgressModal=()=>{$('importProgressModal').classList.remove('hidden')}, closeImportProgressModal=()=>{$('importProgressModal').classList.add('hidden')}, showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`

导入完成
新增: ${added} | 更新: ${updated} | 失败: ${failed}
`;list.innerHTML=results.map(r=>{const statusColor=r.success?(r.status==='added'?'text-green-600':'text-blue-600'):'text-red-600';const statusText=r.status==='added'?'新增':r.status==='updated'?'更新':'失败';return`
${r.email}${statusText}
${r.error?`
${r.error}
`:''}
`}).join('');openImportProgressModal()}, - exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')}, - submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}}, + exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,client_id:t.client_id||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')}, + updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),hints={at:'使用AT更新账号状态(订阅信息、Sora2次数等)',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT,然后更新账号状态',rt:'自动将RT转换为AT(并刷新RT),然后更新账号状态'};hint.textContent=hints[mode]||''}, + submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}const mode=$('importMode').value;try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}}, submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活(之前已接受)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}}, loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}}, saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},