Merge pull request #30 from chenyme/tags

feat: support SSO tags
This commit is contained in:
Chenyme authored and GitHub committed 2025-10-22 16:17:26 +08:00
commit 3e2403095c
3 files changed
+165 -9

No files matched your search

+85 -2
View File
@@ -69,6 +69,7 @@ class TokenInfo(BaseModel):
remaining_queries: int
heavy_remaining_queries: int
status: str # "未使用"、"限流中"、"失效"、"正常"
tags: List[str] = [] # 标签列表
class TokenListResponse(BaseModel):
@@ -300,7 +301,8 @@ async def list_tokens(_: bool = Depends(verify_admin_session)) -> TokenListRespo
created_time=parse_created_time(data.get("createdTime")),
remaining_queries=data.get("remainingQueries", -1),
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
status=get_token_status(data, "sso")
status=get_token_status(data, "sso"),
tags=data.get("tags", []) # 向后兼容,如果没有tags字段则返回空列表
))
# 处理Super Token
@@ -312,7 +314,8 @@ async def list_tokens(_: bool = Depends(verify_admin_session)) -> TokenListRespo
created_time=parse_created_time(data.get("createdTime")),
remaining_queries=data.get("remainingQueries", -1),
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
status=get_token_status(data, "ssoSuper")
status=get_token_status(data, "ssoSuper"),
tags=data.get("tags", []) # 向后兼容,如果没有tags字段则返回空列表
))
normal_count = len(normal_tokens)
@@ -719,3 +722,83 @@ async def get_storage_mode(_: bool = Depends(verify_admin_session)) -> Dict[str,
status_code=500,
detail={"error": f"获取存储模式失败: {str(e)}", "code": "STORAGE_MODE_ERROR"}
)
class UpdateTokenTagsRequest(BaseModel):
"""更新Token标签请求"""
token: str
token_type: str
tags: List[str]
@router.post("/api/tokens/tags")
async def update_token_tags(
request: UpdateTokenTagsRequest,
_: bool = Depends(verify_admin_session)
) -> Dict[str, Any]:
"""
更新Token标签
为指定Token添加或修改标签。
"""
try:
logger.debug(f"[Admin] 更新Token标签 - Token: {request.token[:10]}..., Tags: {request.tags}")
# 验证并转换token类型
token_type = validate_token_type(request.token_type)
# 更新标签
await token_manager.update_token_tags(request.token, token_type, request.tags)
logger.debug(f"[Admin] Token标签更新成功 - Token: {request.token[:10]}..., Tags: {request.tags}")
return {
"success": True,
"message": "标签更新成功",
"tags": request.tags
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[Admin] Token标签更新异常 - Token: {request.token[:10]}..., 错误: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"更新标签失败: {str(e)}", "code": "UPDATE_TAGS_ERROR"}
)
@router.get("/api/tokens/tags/all")
async def get_all_tags(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
"""
获取所有标签
返回系统中所有Token使用的标签列表(去重)。
"""
try:
logger.debug("[Admin] 获取所有标签")
all_tokens_data = token_manager.get_tokens()
tags_set = set()
# 收集所有标签
for token_type_data in all_tokens_data.values():
for token_data in token_type_data.values():
tags = token_data.get("tags", [])
if isinstance(tags, list):
tags_set.update(tags)
tags_list = sorted(list(tags_set))
logger.debug(f"[Admin] 标签获取成功 - 共 {len(tags_list)} 个标签")
return {
"success": True,
"data": tags_list
}
except Exception as e:
logger.error(f"[Admin] 获取标签异常 - 错误: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"获取标签失败: {str(e)}", "code": "GET_TAGS_ERROR"}
)
+19 -1
View File
@@ -134,7 +134,8 @@ class GrokTokenManager:
"status": "active",
"failedCount": 0,
"lastFailureTime": None,
"lastFailureReason": None
"lastFailureReason": None,
"tags": []
}
added_count += 1
@@ -157,6 +158,23 @@ class GrokTokenManager:
await self._save_data()
logger.info(f"[Token] 成功删除 {deleted_count} 个 {token_type.value} Token")
async def update_token_tags(self, token: str, token_type: TokenType, tags: list[str]) -> None:
"""更新Token的标签"""
if token not in self.token_data[token_type.value]:
logger.warning(f"[Token] Token不存在: {token[:10]}...")
raise GrokApiException(
"Token不存在",
"TOKEN_NOT_FOUND",
{"token": token[:10]}
)
# 确保tags是列表且不包含空字符串
cleaned_tags = [tag.strip() for tag in tags if tag and tag.strip()]
self.token_data[token_type.value][token]["tags"] = cleaned_tags
await self._save_data()
logger.info(f"[Token] 成功更新Token {token[:10]}... 的标签: {cleaned_tags}")
def get_tokens(self) -> Dict[str, Any]:
"""获取所有Token数据"""
+61 -6
View File
@@ -150,6 +150,9 @@
<option value="失效">失效</option>
<option value="正常">正常</option>
</select>
<select id="filterTag" onchange="filterTokens()" class="h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring">
<option value="all">全部标签</option>
</select>
</div>
</div>
@@ -222,8 +225,9 @@
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">状态</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">普通剩余</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">高级剩余</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">创建时间</th>
<th class="h-10 px-3 text-right align-middle text-sm font-medium text-muted-foreground w-20">操作</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">标签</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">创建时间</th>
<th class="h-10 px-3 text-right align-middle text-sm font-medium text-muted-foreground w-24">操作</th>
</tr>
</thead>
<tbody id="tokenTableBody" class="divide-y divide-border">
@@ -481,6 +485,50 @@
</div>
</main>
<!-- 编辑标签模态框 -->
<div id="editTagsModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">编辑标签</h3>
<button onclick="closeEditTagsModal()" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Token</label>
<input id="editTagsToken" readonly class="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-xs font-mono" placeholder="">
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">标签 <span class="text-muted-foreground">(多个标签用逗号分隔)</span></label>
<input
id="editTagsInput"
placeholder="例如: 生产环境, 测试用"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div id="suggestedTags" class="flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button
onclick="closeEditTagsModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5"
>
取消
</button>
<button
onclick="submitEditTags()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"
>
保存
</button>
</div>
</div>
</div>
<!-- 添加 Token 模态框 -->
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
@@ -529,20 +577,21 @@
</div>
<script>
let allTokens=[],filteredTokens=[],selectedTokens=new Set();
let allTokens=[],filteredTokens=[],selectedTokens=new Set(),allTagsList=[];
const $=(id)=>document.getElementById(id),
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();if(d.success){const s=d.data;$('statTotal').textContent=s.total||0;['Unused','Limited','Expired','Active'].forEach((k,i)=>$(`stat${k}`).textContent=(s.normal?.[k.toLowerCase()]||0)+(s.super?.[k.toLowerCase()]||0))}}catch(e){console.error('加载统计失败:',e)}},
calcRemaining=()=>{let n=0,h=0;allTokens.forEach(t=>{if(t.remaining_queries>0)n+=t.remaining_queries;if(t.heavy_remaining_queries>0)h+=t.heavy_remaining_queries});return{normal:n,heavy:h,total:n+h}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;const d=await r.json();d.success&&(allTokens=d.data,filteredTokens=allTokens,selectedTokens.clear(),renderTokens(),updateRemaining())}catch(e){console.error('加载列表失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;const d=await r.json();d.success&&(allTokens=d.data.map(t=>({...t,tags:t.tags||[]})),filteredTokens=allTokens,selectedTokens.clear(),renderTokens(),updateRemaining(),await loadAllTags())}catch(e){console.error('加载列表失败:',e)}},
updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
const renderTokens=()=>{const tb=$('tokenTableBody'),es=$('emptyState'),ss={'未使用':'bg-muted text-muted-foreground','限流中':'bg-orange-50 text-orange-700 border-orange-200','失效':'bg-destructive/10 text-destructive border-destructive/20','正常':'bg-green-50 text-green-700 border-green-200'},ts={sso:'bg-blue-50 text-blue-700 border-blue-200',ssoSuper:'bg-purple-50 text-purple-700 border-purple-200'},tl={sso:'SSO',ssoSuper:'SuperSSO'};if(!filteredTokens.length){tb.innerHTML='';es.classList.remove('hidden');$('selectAll').checked=false;return updateBatchActions()}es.classList.add('hidden');tb.innerHTML=filteredTokens.map(t=>`<tr class="transition-colors"><td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token)?'checked':''} onchange="toggleToken('${t.token}')"></td><td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0,30)}...</span><button onclick="copyToken('${t.token.replace(/'/g,"\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="复制完整 Token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status]}">${t.status}</span></td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries===-1?'-':t.heavy_remaining_queries}</td><td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time?new Date(t.created_time).toLocaleString('zh-CN',{dateStyle:'short',timeStyle:'short'}):'-'}</td><td class="py-2.5 px-3 align-middle text-right w-16"><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7"><svg class="h-3.5 w-3.5" 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></button></td></tr>`).join('');updateBatchActions()},
const renderTokens=()=>{const tb=$('tokenTableBody'),es=$('emptyState'),ss={'未使用':'bg-muted text-muted-foreground','限流中':'bg-orange-50 text-orange-700 border-orange-200','失效':'bg-destructive/10 text-destructive border-destructive/20','正常':'bg-green-50 text-green-700 border-green-200'},ts={sso:'bg-blue-50 text-blue-700 border-blue-200',ssoSuper:'bg-purple-50 text-purple-700 border-purple-200'},tl={sso:'SSO',ssoSuper:'SuperSSO'};if(!filteredTokens.length){tb.innerHTML='';es.classList.remove('hidden');$('selectAll').checked=false;return updateBatchActions()}es.classList.add('hidden');tb.innerHTML=filteredTokens.map(t=>{const tagsHtml=t.tags&&t.tags.length?t.tags.map(tag=>`<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">${tag}</span>`).join(' '):'<span class="text-xs text-muted-foreground">-</span>';return`<tr class="transition-colors"><td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token)?'checked':''} onchange="toggleToken('${t.token}')"></td><td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0,30)}...</span><button onclick="copyToken('${t.token.replace(/'/g,"\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="复制完整 Token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status]}">${t.status}</span></td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries===-1?'-':t.heavy_remaining_queries}</td><td class="py-2.5 px-3 align-middle w-32"><div class="flex flex-wrap gap-1">${tagsHtml}</div></td><td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time?new Date(t.created_time).toLocaleString('zh-CN',{dateStyle:'short',timeStyle:'short'}):'-'}</td><td class="py-2.5 px-3 align-middle text-right w-24"><div class="flex items-center justify-end gap-1"><button onclick="editTokenTags('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-7 w-7" title="编辑标签"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7" title="删除"><svg class="h-3.5 w-3.5" 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></button></div></td></tr>`}).join('');updateBatchActions()},
toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
updateBatchActions=()=>{const ba=$('batchActions'),sc=$('selectedCount'),c=selectedTokens.size;ba.classList[c>0?'add':'remove']('flex');ba.classList[c>0?'remove':'add']('hidden');c>0&&(sc.textContent=`已选择 ${c} 项`);$('selectAll').checked=filteredTokens.length>0&&c===filteredTokens.length},
filterTokens=()=>{const tf=$('filterType').value,sf=$('filterStatus').value;filteredTokens=allTokens.filter(t=>(tf==='all'||t.token_type===tf)&&(sf==='all'||t.status===sf));selectedTokens.clear();renderTokens()},
filterTokens=()=>{const tf=$('filterType').value,sf=$('filterStatus').value,tagf=$('filterTag').value;filteredTokens=allTokens.filter(t=>(tf==='all'||t.token_type===tf)&&(sf==='all'||t.status===sf)&&(tagf==='all'||t.tags&&t.tags.includes(tagf)));selectedTokens.clear();renderTokens()},
loadAllTags=async()=>{try{const r=await apiRequest('/api/tokens/tags/all');if(!r)return;const d=await r.json();if(d.success){allTagsList=d.data;const tagFilter=$('filterTag');const currentValue=tagFilter.value;tagFilter.innerHTML='<option value="all">全部标签</option>'+allTagsList.map(tag=>`<option value="${tag}">${tag}</option>`).join('');tagFilter.value=currentValue}}catch(e){console.error('加载标签列表失败:',e)}},
refreshTokens=async()=>{await loadTokens();await loadStats()},
openAddModal=()=>$('addModal').classList.remove('hidden'),
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenList').value=''},
@@ -550,6 +599,12 @@
batchDelete=async()=>{if(!selectedTokens.size||!confirm(`确定要删除选中的 ${selectedTokens.size} 个 Token 吗?此操作不可恢复!`))return;const tbt={sso:[],ssoSuper:[]};document.querySelectorAll('.token-checkbox:checked').forEach(cb=>tbt[cb.dataset.type].push(cb.dataset.token));try{const ps=[];['sso','ssoSuper'].forEach(k=>tbt[k].length&&ps.push(apiRequest('/api/tokens/delete',{method:'POST',body:JSON.stringify({tokens:tbt[k],token_type:k})})));await Promise.all(ps);await refreshTokens()}catch(e){alert('批量删除失败: '+e.message)}},
submitAddTokens=async()=>{const tt=$('addTokenType').value,tks=$('addTokenList').value.split('\n').map(t=>t.trim()).filter(t=>t);if(!tks.length)return alert('请输入至少一个 Token');try{const r=await apiRequest('/api/tokens/add',{method:'POST',body:JSON.stringify({tokens:tks,token_type:tt})});if(!r)return;const d=await r.json();d.success?(closeAddModal(),await refreshTokens()):alert('添加失败: '+(d.error||'未知错误'))}catch(e){alert('添加失败: '+e.message)}},
copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
let currentEditToken='',currentEditTokenType='';
editTokenTags=(token,tokenType)=>{currentEditToken=token;currentEditTokenType=tokenType;const tokenData=allTokens.find(t=>t.token===token);const currentTags=tokenData?.tags||[];$('editTagsToken').value=token.substring(0,50)+'...';$('editTagsInput').value=currentTags.join(', ');const suggestedContainer=$('suggestedTags');suggestedContainer.innerHTML='';if(allTagsList.length>0){suggestedContainer.innerHTML='<div class="text-xs text-muted-foreground mb-1">常用标签:</div>'+allTagsList.map(tag=>`<button onclick="addTagToInput('${tag}')" class="inline-flex items-center rounded px-2 py-1 text-xs bg-muted hover:bg-accent transition-colors">${tag}</button>`).join('')}$('editTagsModal').classList.remove('hidden')},
closeEditTagsModal=()=>{$('editTagsModal').classList.add('hidden');currentEditToken='';currentEditTokenType=''},
addTagToInput=(tag)=>{const input=$('editTagsInput');const currentValue=input.value.trim();const tags=currentValue?currentValue.split(',').map(t=>t.trim()):[];if(!tags.includes(tag)){tags.push(tag);input.value=tags.join(', ')}},
submitEditTags=async()=>{if(!currentEditToken)return;const tagsInput=$('editTagsInput').value;const tags=tagsInput.split(',').map(t=>t.trim()).filter(t=>t);try{const r=await apiRequest('/api/tokens/tags',{method:'POST',body:JSON.stringify({token:currentEditToken,token_type:currentEditTokenType,tags})});if(!r)return;const d=await r.json();if(d.success){closeEditTagsModal();await refreshTokens();showToast('标签更新成功','success')}else{showToast('更新失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}}
const exportSelected=()=>{if(!selectedTokens.size)return showToast('请先选择要导出的 Token','error');const sd=allTokens.filter(t=>selectedTokens.has(t.token)),csv=[['Token','类型','状态','普通调用剩余','高级调用剩余','创建时间'].join(','),...sd.map(t=>[`"${t.token}"`,t.token_type==='sso'?'SSO':'SuperSSO',t.status,t.remaining_queries===-1?'未使用':t.remaining_queries,t.heavy_remaining_queries===-1?'未使用':t.heavy_remaining_queries,`"${t.created_time?new Date(t.created_time).toLocaleString('zh-CN'):'-'}"`].join(','))].join('\n'),l=document.createElement('a');l.href=URL.createObjectURL(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}));l.download=`grok_tokens_${new Date().toISOString().slice(0,10)}.csv`;l.style.display='none';document.body.appendChild(l);l.click();document.body.removeChild(l);URL.revokeObjectURL(l.href);showToast(`已导出 ${selectedTokens.size} 个 Token`,'success')}
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}