feat: 新增Token批量管理功能(测试更新/批量启用/清理禁用)

This commit is contained in:
TheSmallHanCat
2026-01-11 15:34:02 +08:00
parent fb0569c298
commit b23f60e66b
3 changed files with 98 additions and 1 deletions

View File

@@ -348,6 +348,79 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/tokens/batch/test-update")
async def batch_test_update(token: str = Depends(verify_admin_token)):
"""Test and update all tokens by fetching their status from upstream"""
try:
tokens = await db.get_all_tokens()
success_count = 0
failed_count = 0
results = []
for token_obj in tokens:
try:
# Test token and update account info (same as single test)
result = await token_manager.test_token(token_obj.id)
if result.get("valid"):
success_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "success"})
else:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "failed", "message": result.get("message")})
except Exception as e:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "error", "message": str(e)})
return {
"success": True,
"message": f"测试完成:成功 {success_count} 个,失败 {failed_count}",
"success_count": success_count,
"failed_count": failed_count,
"results": results
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/enable-all")
async def batch_enable_all(token: str = Depends(verify_admin_token)):
"""Enable all disabled tokens"""
try:
tokens = await db.get_all_tokens()
enabled_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.enable_token(token_obj.id)
enabled_count += 1
return {
"success": True,
"message": f"已启用 {enabled_count} 个禁用的Token",
"enabled_count": enabled_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/delete-disabled")
async def batch_delete_disabled(token: str = Depends(verify_admin_token)):
"""Delete all disabled tokens"""
try:
tokens = await db.get_all_tokens()
deleted_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.delete_token(token_obj.id)
deleted_count += 1
return {
"success": True,
"message": f"已删除 {deleted_count} 个禁用的Token",
"deleted_count": deleted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/import")
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
"""Import tokens with different modes: offline/at/st/rt"""

View File

@@ -1529,7 +1529,7 @@
</div>
<div>
<label class="section-title" for="baseUrl" style="margin-bottom:4px;">服务器地址</label>
<input id="baseUrl" class="input" type="text" value="http://127.0.0.1:8080" placeholder="后端地址,默认本机">
<input id="baseUrl" class="input" type="text" value="http://127.0.0.1:8000" placeholder="后端地址,默认本机">
</div>
</div>

View File

@@ -101,6 +101,27 @@
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button onclick="batchTestUpdate()" class="inline-flex items-center justify-center rounded-md bg-purple-600 text-white hover:bg-purple-700 h-8 px-3" title="一键测试更新所有Token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span class="text-sm font-medium">测试更新</span>
</button>
<button onclick="batchEnableAll()" class="inline-flex items-center justify-center rounded-md bg-teal-600 text-white hover:bg-teal-700 h-8 px-3" title="一键启用所有禁用的Token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="text-sm font-medium">批量启用</span>
</button>
<button onclick="batchDeleteDisabled()" class="inline-flex items-center justify-center rounded-md bg-red-600 text-white hover:bg-red-700 h-8 px-3" title="一键清理所有禁用的Token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
<span class="text-sm font-medium">清理禁用</span>
</button>
<button onclick="exportTokens()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 h-8 px-3" title="导出所有Token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
@@ -789,6 +810,9 @@
closeImportProgressModal=()=>{$('importProgressModal').classList.add('hidden')},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;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`<div class="p-3 rounded-md border ${r.success?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${r.error?`<div class="text-xs text-red-600 dark:text-red-400 mt-1">${r.error}</div>`:''}</div>`}).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,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')},
batchTestUpdate=async()=>{if(!confirm('⚠️ 警告\n\n此操作将请求上游获取所有Token的状态信息可能需要较长时间。\n\n确定要继续吗')){return}showToast('正在测试更新所有Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
batchEnableAll=async()=>{const disabledCount=allTokens.filter(t=>!t.is_active).length;if(disabledCount===0){showToast('没有禁用的Token','info');return}if(!confirm(`确定要启用所有 ${disabledCount} 个禁用的Token吗\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
batchDeleteDisabled=async()=>{const disabledCount=allTokens.filter(t=>!t.is_active).length;if(disabledCount===0){showToast('没有禁用的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除所有 ${disabledCount} 个禁用的Token。\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${disabledCount} 个禁用的Token吗\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${disabledCount} 个禁用的Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在清理禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('清理失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
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')}},