Merge pull request #32 from chenyme/test

feat: support test SSO
This commit is contained in:
Chenyme authored and GitHub committed 2025-10-22 17:34:02 +08:00
commit 192fb31ea3
3 files changed
+264 -22

No files matched your search

+157 -2
View File
@@ -70,6 +70,7 @@ class TokenInfo(BaseModel):
heavy_remaining_queries: int
status: str # "未使用"、"限流中"、"失效"、"正常"
tags: List[str] = [] # 标签列表
note: str = "" # 备注
class TokenListResponse(BaseModel):
@@ -302,7 +303,8 @@ async def list_tokens(_: bool = Depends(verify_admin_session)) -> TokenListRespo
remaining_queries=data.get("remainingQueries", -1),
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
status=get_token_status(data, "sso"),
tags=data.get("tags", []) # 向后兼容,如果没有tags字段则返回空列表
tags=data.get("tags", []), # 向后兼容,如果没有tags字段则返回空列表
note=data.get("note", "") # 向后兼容,如果没有note字段则返回空字符串
))
# 处理Super Token
@@ -315,7 +317,8 @@ async def list_tokens(_: bool = Depends(verify_admin_session)) -> TokenListRespo
remaining_queries=data.get("remainingQueries", -1),
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
status=get_token_status(data, "ssoSuper"),
tags=data.get("tags", []) # 向后兼容,如果没有tags字段则返回空列表
tags=data.get("tags", []), # 向后兼容,如果没有tags字段则返回空列表
note=data.get("note", "") # 向后兼容,如果没有note字段则返回空字符串
))
normal_count = len(normal_tokens)
@@ -802,3 +805,155 @@ async def get_all_tags(_: bool = Depends(verify_admin_session)) -> Dict[str, Any
status_code=500,
detail={"error": f"获取标签失败: {str(e)}", "code": "GET_TAGS_ERROR"}
)
class UpdateTokenNoteRequest(BaseModel):
"""更新Token备注请求"""
token: str
token_type: str
note: str
@router.post("/api/tokens/note")
async def update_token_note(
request: UpdateTokenNoteRequest,
_: bool = Depends(verify_admin_session)
) -> Dict[str, Any]:
"""
更新Token备注
为指定Token添加或修改备注信息。
"""
try:
logger.debug(f"[Admin] 更新Token备注 - Token: {request.token[:10]}...")
# 验证并转换token类型
token_type = validate_token_type(request.token_type)
# 更新备注
await token_manager.update_token_note(request.token, token_type, request.note)
logger.debug(f"[Admin] Token备注更新成功 - Token: {request.token[:10]}...")
return {
"success": True,
"message": "备注更新成功",
"note": request.note
}
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_NOTE_ERROR"}
)
class TestTokenRequest(BaseModel):
"""测试Token请求"""
token: str
token_type: str
@router.post("/api/tokens/test")
async def test_token(
request: TestTokenRequest,
_: bool = Depends(verify_admin_session)
) -> Dict[str, Any]:
"""
测试Token可用性
通过发送速率限制检查请求来验证Token是否有效。
根据不同的HTTP状态码进行相应的处理:
- 401: Token失效
- 403: 服务器被block,不改变Token状态
- 其他错误: 设置为限流状态
"""
try:
logger.debug(f"[Admin] 测试Token - Token: {request.token[:10]}...")
# 验证并转换token类型
token_type = validate_token_type(request.token_type)
# 构造完整的auth token
auth_token = f"sso-rw={request.token};sso={request.token}"
# 使用check_limits方法测试token
result = await token_manager.check_limits(auth_token, "grok-4-fast")
if result:
logger.debug(f"[Admin] Token测试成功 - Token: {request.token[:10]}...")
return {
"success": True,
"message": "Token有效",
"data": {
"valid": True,
"remaining_queries": result.get("remainingTokens", -1),
"limit": result.get("limit", -1)
}
}
else:
# 测试失败,check_limits方法已经调用了record_failure处理错误
# 现在检查token的状态来判断错误类型
logger.warning(f"[Admin] Token测试失败 - Token: {request.token[:10]}...")
# 检查token当前状态
all_tokens = token_manager.get_tokens()
token_data = all_tokens.get(token_type.value, {}).get(request.token)
if token_data:
if token_data.get("status") == "expired":
# Token被标记为失效(401错误)
return {
"success": False,
"message": "Token已失效",
"data": {
"valid": False,
"error_type": "expired",
"error_code": 401
}
}
elif token_data.get("remainingQueries") == 0:
# Token被设置为限流状态(其他错误)
return {
"success": False,
"message": "Token已被限流",
"data": {
"valid": False,
"error_type": "limited",
"error_code": "other"
}
}
else:
# 可能是403错误或其他网络问题,token状态未变
return {
"success": False,
"message": "服务器被block或网络错误",
"data": {
"valid": False,
"error_type": "blocked",
"error_code": 403
}
}
else:
# 找不到token数据
return {
"success": False,
"message": "Token数据异常",
"data": {
"valid": False,
"error_type": "unknown",
"error_code": "data_error"
}
}
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"测试Token失败: {str(e)}", "code": "TEST_TOKEN_ERROR"}
)
+31 -1
View File
@@ -135,7 +135,8 @@ class GrokTokenManager:
"failedCount": 0,
"lastFailureTime": None,
"lastFailureReason": None,
"tags": []
"tags": [],
"note": ""
}
added_count += 1
@@ -175,6 +176,21 @@ class GrokTokenManager:
await self._save_data()
logger.info(f"[Token] 成功更新Token {token[:10]}... 的标签: {cleaned_tags}")
async def update_token_note(self, token: str, token_type: TokenType, note: 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]}
)
self.token_data[token_type.value][token]["note"] = note.strip()
await self._save_data()
logger.info(f"[Token] 成功更新Token {token[:10]}... 的备注")
def get_tokens(self) -> Dict[str, Any]:
"""获取所有Token数据"""
@@ -308,6 +324,20 @@ class GrokTokenManager:
return rate_limit_data
else:
logger.warning(f"[Token] 获取速率限制失败,状态码: {response.status_code}")
# 根据HTTP状态码处理不同的错误
sso_value = self._extract_sso(auth_token)
if sso_value:
if response.status_code == 401:
# Token失效
await self.record_failure(auth_token, 401, "Token authentication failed")
elif response.status_code == 403:
# 服务器被block,不改变token状态,但记录失败信息
await self.record_failure(auth_token, 403, "Server blocked (Cloudflare)")
else:
# 其他错误,设置为限流状态
await self.record_failure(auth_token, response.status_code, f"Rate limit or other error: {response.status_code}")
return None
except Exception as e:
+76 -19
View File
@@ -21,8 +21,53 @@
.hover-card-content.bottom::after{bottom:100%;border-bottom-color:hsl(0 0% 3.9%)}
.hover-card-trigger:hover+.hover-card-content{opacity:1;visibility:visible}
[title]{position:relative}
[title]:hover::after{content:attr(title);position:absolute;bottom:100%;left:50%;transform:translateX(-50%) translateY(-4px);background:hsl(0 0% 3.9%);color:hsl(0 0% 98%);padding:8px 12px;border-radius:4px;font-size:11px;white-space:pre-line;max-width:500px;min-width:300px;word-wrap:break-word;z-index:1000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.15)}
[title]:hover::before{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:4px solid transparent;border-top-color:hsl(0 0% 3.9%);z-index:1000}
[title]:hover::after{
content:attr(title);
position:absolute;
bottom:100%;
left:50%;
transform:translateX(-50%) translateY(-8px);
background:hsl(0 0% 11%);
color:hsl(0 0% 98%);
padding:8px 12px;
border-radius:6px;
font-size:12px;
font-weight:500;
line-height:1.4;
white-space:pre-line;
max-width:350px;
width:max-content;
word-wrap:break-word;
z-index:1000;
pointer-events:none;
box-shadow:0 8px 20px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1);
opacity:0;
visibility:hidden;
transition:all 0.2s ease;
animation:tooltipFadeIn 0.2s ease forwards;
}
[title]:hover::before{
content:'';
position:absolute;
bottom:100%;
left:50%;
transform:translateX(-50%) translateY(-4px);
border:6px solid transparent;
border-top-color:hsl(0 0% 11%);
z-index:1000;
opacity:0;
visibility:hidden;
transition:opacity 0.2s ease, visibility 0.2s ease;
}
[title]:hover::after,
[title]:hover::before{
opacity:1;
visibility:visible;
}
@keyframes tooltipFadeIn{
from{opacity:0;transform:translateX(-50%) translateY(-4px)}
to{opacity:1;transform:translateX(-50%) translateY(-8px)}
}
</style>
<script>
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},accent:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
@@ -138,19 +183,19 @@
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
<div class="flex items-center gap-3 flex-1">
<div class="flex items-center gap-2">
<select id="filterType" 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">
<select id="filterType" onchange="filterTokens()" class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">全部类型</option>
<option value="sso">SSO</option>
<option value="ssoSuper">SuperSSO</option>
</select>
<select id="filterStatus" 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">
<select id="filterStatus" onchange="filterTokens()" class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">全部状态</option>
<option value="未使用">未使用</option>
<option value="限流中">限流中</option>
<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">
<select id="filterTag" onchange="filterTokens()" class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">全部标签</option>
</select>
</div>
@@ -223,11 +268,12 @@
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-72">Token</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-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-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-40">备注</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>
<th class="h-10 px-3 text-right align-middle text-sm font-medium text-muted-foreground w-28">操作</th>
</tr>
</thead>
<tbody id="tokenTableBody" class="divide-y divide-border">
@@ -485,12 +531,12 @@
</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">
<h3 class="text-lg font-semibold">编辑信息</h3>
<button onclick="closeEditModal()" 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"/>
@@ -500,7 +546,7 @@
<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="">
<input id="editTokenInput" 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>
@@ -511,16 +557,25 @@
/>
<div id="suggestedTags" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">备注</label>
<textarea
id="editNoteInput"
rows="3"
placeholder="添加备注信息..."
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none"
></textarea>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button
onclick="closeEditTagsModal()"
onclick="closeEditModal()"
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()"
onclick="submitEditInfo()"
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"
>
保存
@@ -583,10 +638,10 @@
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.map(t=>({...t,tags:t.tags||[]})),filteredTokens=allTokens,selectedTokens.clear(),renderTokens(),updateRemaining(),await loadAllTags())}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||[],note:t.note||''})),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=>{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()},
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>';const noteHtml=t.note&&t.note.length?`<span class="text-xs text-gray-700" title="${t.note}">${t.note.length>20?t.note.substring(0,20)+'...':t.note}</span>`:'<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-40">${noteHtml}</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-28"><div class="flex items-center justify-end gap-1"><button onclick="testToken('${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-blue-50 hover:text-blue-700 h-7 w-7" title="测试Token"><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="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></button><button onclick="editToken('${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},
@@ -601,10 +656,12 @@
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=''},
editToken=(token,tokenType)=>{currentEditToken=token;currentEditTokenType=tokenType;const tokenData=allTokens.find(t=>t.token===token);const currentTags=tokenData?.tags||[];const currentNote=tokenData?.note||'';$('editTokenInput').value=token.substring(0,50)+'...';$('editTagsInput').value=currentTags.join(', ');$('editNoteInput').value=currentNote;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')},
closeEditModal=()=>{$('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')}}
submitEditInfo=async()=>{if(!currentEditToken)return;const tagsInput=$('editTagsInput').value;const note=$('editNoteInput').value.trim();const tags=tagsInput.split(',').map(t=>t.trim()).filter(t=>t);const promises=[];promises.push(apiRequest('/api/tokens/tags',{method:'POST',body:JSON.stringify({token:currentEditToken,token_type:currentEditTokenType,tags})}));promises.push(apiRequest('/api/tokens/note',{method:'POST',body:JSON.stringify({token:currentEditToken,token_type:currentEditTokenType,note})}));try{const results=await Promise.all(promises);const allSuccess=results.every(r=>r&&r.ok);if(allSuccess){closeEditModal();await refreshTokens();showToast('信息更新成功','success')}else{showToast('部分更新失败,请重试','error')}}catch(e){showToast('更新失败: '+e.message,'error')}}
testToken=async(token,tokenType)=>{const btn=event.target.closest('button');const originalHtml=btn.innerHTML;btn.disabled=true;btn.innerHTML='<svg class="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';try{const r=await apiRequest('/api/tokens/test',{method:'POST',body:JSON.stringify({token,token_type:tokenType})});if(!r)return;const d=await r.json();if(d.success&&d.data.valid){showToast(`Token有效!剩余: ${d.data.remaining_queries===-1?'无限制':d.data.remaining_queries}次`,'success');await refreshTokens()}else{const errorData=d.data||{};const errorType=errorData.error_type;if(errorType==='expired'){showToast('Token已失效 (401错误)','error')}else if(errorType==='blocked'){showToast('服务器被block,请稍后再试或更换IP','error')}else if(errorType==='limited'){showToast('Token已被限流','error')}else{showToast('Token无效或已失效','error')}}}catch(e){showToast('测试失败: '+e.message,'error')}finally{btn.disabled=false;btn.innerHTML=originalHtml}}
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)}