mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-04 02:04:42 +08:00
feat: 新增Token批量选择及批量禁用、优化请求日志进度输出、新增Token批量修改代理功能
refactor: 移除Sora2激活码相关功能
This commit is contained in:
182
src/api/admin.py
182
src/api/admin.py
@@ -147,7 +147,13 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
|
||||
watermark_free_enabled: bool
|
||||
parse_method: Optional[str] = "third_party" # "third_party" or "custom"
|
||||
custom_parse_url: Optional[str] = None
|
||||
custom_parse_token: Optional[str] = None
|
||||
|
||||
class BatchDisableRequest(BaseModel):
|
||||
token_ids: List[int]
|
||||
|
||||
class BatchUpdateProxyRequest(BaseModel):
|
||||
token_ids: List[int]
|
||||
proxy_url: Optional[str] = None
|
||||
|
||||
# Auth endpoints
|
||||
@router.post("/api/login", response_model=LoginResponse)
|
||||
@@ -317,7 +323,7 @@ async def disable_token(token_id: int, token: str = Depends(verify_admin_token))
|
||||
|
||||
@router.post("/api/tokens/{token_id}/test")
|
||||
async def test_token(token_id: int, token: str = Depends(verify_admin_token)):
|
||||
"""Test if a token is valid and refresh Sora2 info"""
|
||||
"""Test if a token is valid"""
|
||||
try:
|
||||
result = await token_manager.test_token(token_id)
|
||||
response = {
|
||||
@@ -328,16 +334,6 @@ async def test_token(token_id: int, token: str = Depends(verify_admin_token)):
|
||||
"username": result.get("username")
|
||||
}
|
||||
|
||||
# Include Sora2 info if available
|
||||
if result.get("valid"):
|
||||
response.update({
|
||||
"sora2_supported": result.get("sora2_supported"),
|
||||
"sora2_invite_code": result.get("sora2_invite_code"),
|
||||
"sora2_redeemed_count": result.get("sora2_redeemed_count"),
|
||||
"sora2_total_count": result.get("sora2_total_count"),
|
||||
"sora2_remaining_count": result.get("sora2_remaining_count")
|
||||
})
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -352,10 +348,20 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
|
||||
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"""
|
||||
async def batch_test_update(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
|
||||
"""Test and update selected tokens or all tokens by fetching their status from upstream"""
|
||||
try:
|
||||
tokens = await db.get_all_tokens()
|
||||
if request and request.token_ids:
|
||||
# Test only selected tokens
|
||||
tokens = []
|
||||
for token_id in request.token_ids:
|
||||
token_obj = await db.get_token(token_id)
|
||||
if token_obj:
|
||||
tokens.append(token_obj)
|
||||
else:
|
||||
# Test all tokens (backward compatibility)
|
||||
tokens = await db.get_all_tokens()
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
@@ -385,45 +391,96 @@ async def batch_test_update(token: str = Depends(verify_admin_token)):
|
||||
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"""
|
||||
async def batch_enable_all(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
|
||||
"""Enable selected tokens or 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)
|
||||
if request and request.token_ids:
|
||||
# Enable only selected tokens
|
||||
enabled_count = 0
|
||||
for token_id in request.token_ids:
|
||||
await token_manager.enable_token(token_id)
|
||||
enabled_count += 1
|
||||
else:
|
||||
# Enable all disabled tokens (backward compatibility)
|
||||
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",
|
||||
"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"""
|
||||
async def batch_delete_disabled(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
|
||||
"""Delete selected tokens or 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)
|
||||
if request and request.token_ids:
|
||||
# Delete only selected tokens
|
||||
deleted_count = 0
|
||||
for token_id in request.token_ids:
|
||||
await token_manager.delete_token(token_id)
|
||||
deleted_count += 1
|
||||
else:
|
||||
# Delete all disabled tokens (backward compatibility)
|
||||
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",
|
||||
"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/batch/disable-selected")
|
||||
async def batch_disable_selected(request: BatchDisableRequest, token: str = Depends(verify_admin_token)):
|
||||
"""Disable selected tokens"""
|
||||
try:
|
||||
disabled_count = 0
|
||||
for token_id in request.token_ids:
|
||||
await token_manager.disable_token(token_id)
|
||||
disabled_count += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已禁用 {disabled_count} 个Token",
|
||||
"disabled_count": disabled_count
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/api/tokens/batch/update-proxy")
|
||||
async def batch_update_proxy(request: BatchUpdateProxyRequest, token: str = Depends(verify_admin_token)):
|
||||
"""Batch update proxy for selected tokens"""
|
||||
try:
|
||||
updated_count = 0
|
||||
for token_id in request.token_ids:
|
||||
await token_manager.update_token(
|
||||
token_id=token_id,
|
||||
proxy_url=request.proxy_url
|
||||
)
|
||||
updated_count += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已更新 {updated_count} 个Token的代理",
|
||||
"updated_count": updated_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"""
|
||||
@@ -801,65 +858,6 @@ async def get_stats(token: str = Depends(verify_admin_token)):
|
||||
"today_errors": today_errors
|
||||
}
|
||||
|
||||
# Sora2 endpoints
|
||||
@router.post("/api/tokens/{token_id}/sora2/activate")
|
||||
async def activate_sora2(
|
||||
token_id: int,
|
||||
invite_code: str,
|
||||
token: str = Depends(verify_admin_token)
|
||||
):
|
||||
"""Activate Sora2 with invite code"""
|
||||
try:
|
||||
# Get token
|
||||
token_obj = await db.get_token(token_id)
|
||||
if not token_obj:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
# Activate Sora2
|
||||
result = await token_manager.activate_sora2_invite(token_obj.token, invite_code)
|
||||
|
||||
if result.get("success"):
|
||||
# Get new invite code after activation
|
||||
sora2_info = await token_manager.get_sora2_invite_code(token_obj.token, token_id)
|
||||
|
||||
# Get remaining count
|
||||
sora2_remaining_count = 0
|
||||
try:
|
||||
remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token, token_id)
|
||||
if remaining_info.get("success"):
|
||||
sora2_remaining_count = remaining_info.get("remaining_count", 0)
|
||||
except Exception as e:
|
||||
print(f"Failed to get Sora2 remaining count: {e}")
|
||||
|
||||
# Update database
|
||||
await db.update_token_sora2(
|
||||
token_id,
|
||||
supported=True,
|
||||
invite_code=sora2_info.get("invite_code"),
|
||||
redeemed_count=sora2_info.get("redeemed_count", 0),
|
||||
total_count=sora2_info.get("total_count", 0),
|
||||
remaining_count=sora2_remaining_count
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Sora2 activated successfully",
|
||||
"already_accepted": result.get("already_accepted", False),
|
||||
"invite_code": sora2_info.get("invite_code"),
|
||||
"redeemed_count": sora2_info.get("redeemed_count", 0),
|
||||
"total_count": sora2_info.get("total_count", 0),
|
||||
"sora2_remaining_count": sora2_remaining_count
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to activate Sora2"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to activate Sora2: {str(e)}")
|
||||
|
||||
# Logs endpoints
|
||||
@router.get("/api/logs")
|
||||
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
|
||||
|
||||
@@ -917,7 +917,17 @@ class Database:
|
||||
query = f"UPDATE request_logs SET {', '.join(updates)} WHERE id = ?"
|
||||
await db.execute(query, params)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def update_request_log_task_id(self, log_id: int, task_id: str):
|
||||
"""Update request log with task_id"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute("""
|
||||
UPDATE request_logs
|
||||
SET task_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (task_id, log_id))
|
||||
await db.commit()
|
||||
|
||||
async def get_recent_logs(self, limit: int = 100) -> List[dict]:
|
||||
"""Get recent logs with token email"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
|
||||
@@ -491,8 +491,21 @@ class GenerationHandler:
|
||||
|
||||
task_id = None
|
||||
is_first_chunk = True # Track if this is the first chunk
|
||||
log_id = None # Initialize log_id
|
||||
|
||||
try:
|
||||
# Create initial log entry BEFORE submitting task to upstream
|
||||
# This ensures the log is created even if upstream fails
|
||||
log_id = await self._log_request(
|
||||
token_obj.id,
|
||||
f"generate_{model_config['type']}",
|
||||
{"model": model, "prompt": prompt, "has_image": image is not None},
|
||||
{}, # Empty response initially
|
||||
-1, # -1 means in-progress
|
||||
-1.0, # -1.0 means in-progress
|
||||
task_id=None # Will be updated after task submission
|
||||
)
|
||||
|
||||
# Upload image if provided
|
||||
media_id = None
|
||||
if image:
|
||||
@@ -573,7 +586,7 @@ class GenerationHandler:
|
||||
media_id=media_id,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
|
||||
|
||||
# Save task to database
|
||||
task = Task(
|
||||
task_id=task_id,
|
||||
@@ -585,16 +598,9 @@ class GenerationHandler:
|
||||
)
|
||||
await self.db.create_task(task)
|
||||
|
||||
# Create initial log entry (status_code=-1, duration=-1.0 means in-progress)
|
||||
log_id = await self._log_request(
|
||||
token_obj.id,
|
||||
f"generate_{model_config['type']}",
|
||||
{"model": model, "prompt": prompt, "has_image": image is not None},
|
||||
{}, # Empty response initially
|
||||
-1, # -1 means in-progress
|
||||
-1.0, # -1.0 means in-progress
|
||||
task_id=task_id
|
||||
)
|
||||
# Update log entry with task_id now that we have it
|
||||
if log_id:
|
||||
await self.db.update_request_log_task_id(log_id, task_id)
|
||||
|
||||
# Record usage
|
||||
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
|
||||
@@ -787,6 +793,9 @@ class GenerationHandler:
|
||||
last_progress = progress_pct
|
||||
status = task.get("status", "processing")
|
||||
|
||||
# Update database with current progress
|
||||
await self.db.update_task(task_id, "processing", progress_pct)
|
||||
|
||||
# Output status every 30 seconds (not just when progress changes)
|
||||
current_time = time.time()
|
||||
if stream and (current_time - last_status_output_time >= video_status_interval):
|
||||
|
||||
@@ -101,27 +101,137 @@
|
||||
<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>
|
||||
<!-- 批量操作下拉菜单 -->
|
||||
<style>
|
||||
.batch-dropdown-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.batch-dropdown-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.batch-dropdown-btn:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
.batch-dropdown-arrow {
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.batch-dropdown-container:hover .batch-dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.batch-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.batch-dropdown-container:hover .batch-dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.batch-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.batch-dropdown-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.batch-dropdown-item.purple:hover { background: #faf5ff; color: #9333ea; }
|
||||
.batch-dropdown-item.teal:hover { background: #f0fdfa; color: #0d9488; }
|
||||
.batch-dropdown-item.orange:hover { background: #fff7ed; color: #ea580c; }
|
||||
.batch-dropdown-item.red:hover { background: #fef2f2; color: #dc2626; }
|
||||
.batch-dropdown-item.blue:hover { background: #eff6ff; color: #2563eb; }
|
||||
.batch-dropdown-item + .batch-dropdown-item {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.batch-dropdown-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
<div class="batch-dropdown-container">
|
||||
<button class="batch-dropdown-btn" title="批量操作">
|
||||
<svg class="h-4 w-4 mr-1" 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>
|
||||
<svg class="batch-dropdown-arrow h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="batch-dropdown-menu">
|
||||
<button onclick="batchTestUpdate()" class="batch-dropdown-item purple" title="一键测试更新所有Token">
|
||||
<svg class="batch-dropdown-icon" 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>测试更新</span>
|
||||
</button>
|
||||
<button onclick="batchEnableAll()" class="batch-dropdown-item teal" title="一键启用所有禁用的Token">
|
||||
<svg class="batch-dropdown-icon" 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>批量启用</span>
|
||||
</button>
|
||||
<button onclick="batchDisableSelected()" class="batch-dropdown-item orange" title="批量禁用选中的Token">
|
||||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<span>批量禁用</span>
|
||||
</button>
|
||||
<button onclick="batchDeleteDisabled()" class="batch-dropdown-item red" title="一键清理所有禁用的Token">
|
||||
<svg class="batch-dropdown-icon" 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>清理禁用</span>
|
||||
</button>
|
||||
<button onclick="openBatchProxyModal()" class="batch-dropdown-item blue" title="批量修改选中Token的代理">
|
||||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6m-5.2-9.8l4.2 4.2m4.2 0l4.2-4.2m-12.6 0l4.2 4.2m4.2 0l4.2 4.2"/>
|
||||
</svg>
|
||||
<span>修改代理</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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"/>
|
||||
@@ -152,13 +262,14 @@
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">
|
||||
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" class="h-4 w-4 rounded border-gray-300">
|
||||
</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">邮箱</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Client ID</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">账户类型</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Sora2</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">可用次数</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
|
||||
@@ -650,39 +761,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora2 激活模态框 -->
|
||||
<div id="sora2Modal" 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">激活 Sora2</h3>
|
||||
<button onclick="closeSora2Modal()" class="text-muted-foreground hover:text-foreground">
|
||||
<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">
|
||||
<input type="hidden" id="sora2TokenId">
|
||||
<div>
|
||||
<label class="text-sm font-medium mb-2 block">Sora2 邀请码</label>
|
||||
<input id="sora2InviteCode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入6位邀请码,例如:0ZSKEG">
|
||||
<p class="text-xs text-muted-foreground mt-1">输入Sora2邀请码以激活该Token的Sora2功能</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
||||
<button onclick="closeSora2Modal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
||||
<button id="sora2ActivateBtn" onclick="submitSora2Activate()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
||||
<span id="sora2ActivateBtnText">激活</span>
|
||||
<svg id="sora2ActivateBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token 导入模态框 -->
|
||||
<div id="importModal" 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">
|
||||
@@ -775,8 +853,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量修改代理模态框 -->
|
||||
<div id="batchProxyModal" 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="closeBatchProxyModal()" class="text-muted-foreground hover:text-foreground">
|
||||
<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>
|
||||
<label class="text-sm font-medium mb-2 block">代理地址</label>
|
||||
<input type="text" id="batchProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||||
<p class="text-xs text-muted-foreground mt-1">支持 http 和 socks5 代理,留空则清空代理设置</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-xs text-blue-900 dark:text-blue-100">
|
||||
<span class="font-semibold">提示:</span>将为 <span id="batchProxyCount" class="font-semibold">0</span> 个选中的Token修改代理地址
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
||||
<button onclick="closeBatchProxyModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
||||
<button id="batchProxyBtn" onclick="submitBatchProxy()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
||||
<svg id="batchProxyBtnSpinner" class="hidden animate-spin -ml-1 mr-2 h-4 w-4" 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>
|
||||
<span id="batchProxyBtnText">确认修改</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTokens=[],currentPage=1,pageSize=20;
|
||||
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set();
|
||||
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},
|
||||
@@ -784,11 +899,9 @@
|
||||
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
||||
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
||||
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
||||
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`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}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`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
||||
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
|
||||
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
||||
renderTokens=()=>{const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=allTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=(t.video_enabled&&t.sora2_supported)?`${t.video_count||0}`:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3 text-xs">${formatSora2(t)}</td><td class="py-2.5 px-3">${formatSora2Remaining(t)}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
||||
renderTokens=()=>{const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=allTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||
changePage=(page)=>{currentPage=page;renderTokens()},
|
||||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
||||
@@ -807,21 +920,23 @@
|
||||
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')}},
|
||||
deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失败','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}},
|
||||
copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
|
||||
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
|
||||
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
|
||||
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
|
||||
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
|
||||
openImportProgressModal=()=>{$('importProgressModal').classList.remove('hidden')},
|
||||
closeImportProgressModal=()=>{$('importProgressModal').classList.add('hidden')},
|
||||
openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('hidden')},
|
||||
closeBatchProxyModal=()=>{$('batchProxyModal').classList.add('hidden');$('batchProxyUrl').value=''},
|
||||
submitBatchProxy=async()=>{const proxyUrl=$('batchProxyUrl').value.trim();const btn=$('batchProxyBtn'),btnText=$('batchProxyBtnText'),btnSpinner=$('batchProxyBtnSpinner');btn.disabled=true;btnText.textContent='修改中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/batch/update-proxy',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds),proxy_url:proxyUrl})});if(!r){btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeBatchProxyModal();selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('修改失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='确认修改';btnSpinner.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')}},
|
||||
batchTestUpdate=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要测试的Token','info');return}if(!confirm(`⚠️ 警告\n\n此操作将请求上游获取选中的 ${selectedTokenIds.size} 个Token的状态信息,可能需要较长时间。\n\n确定要继续吗?`)){return}showToast('正在测试更新选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
|
||||
batchEnableAll=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要启用的Token','info');return}if(!confirm(`确定要启用选中的 ${selectedTokenIds.size} 个Token吗?\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
|
||||
batchDeleteDisabled=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除选中的 ${selectedTokenIds.size} 个Token。\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${selectedTokenIds.size} 个Token吗?\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${selectedTokenIds.size} 个Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在删除选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||||
toggleSelectAll=()=>{const checkbox=$('selectAllCheckbox');const checkboxes=document.querySelectorAll('.token-checkbox');if(checkbox.checked){checkboxes.forEach(cb=>{cb.checked=true;const tokenId=parseInt(cb.getAttribute('data-token-id'));selectedTokenIds.add(tokenId)})}else{checkboxes.forEach(cb=>{cb.checked=false});selectedTokenIds.clear()}},
|
||||
toggleTokenSelection=(tokenId,checked)=>{if(checked){selectedTokenIds.add(tokenId)}else{selectedTokenIds.delete(tokenId)}const allCheckboxes=document.querySelectorAll('.token-checkbox');const allChecked=Array.from(allCheckboxes).every(cb=>cb.checked);$('selectAllCheckbox').checked=allChecked},
|
||||
batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗?`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();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')}},
|
||||
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')}},
|
||||
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
|
||||
|
||||
Reference in New Issue
Block a user