mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-04 02:04:42 +08:00
feat: 新增提示词增强模型、Token定时自动刷新、新增分页、新增任务终止及进度显示优化
This commit is contained in:
59
README.md
59
README.md
@@ -119,6 +119,7 @@ python main.py
|
||||
| 角色生成视频 | `sora2-*` | 使用 `content` 数组 + `video_url` + 文本 |
|
||||
| Remix | `sora2-*` | 在 `content` 中包含 Remix ID |
|
||||
| 视频分镜 | `sora2-*` | 在 `content` 中使用```[时长s]提示词```格式触发 |
|
||||
| 提示词优化 | `prompt-enhance-*` | 将简单提示词扩展为详细的电影级提示词 |
|
||||
|
||||
---
|
||||
|
||||
@@ -175,6 +176,28 @@ python main.py
|
||||
|
||||
> **注意:** Pro 系列模型需要 ChatGPT Pro 订阅(`plan_type: "chatgpt_pro"`)。如果没有 Pro 账号,请求这些模型会返回错误。
|
||||
|
||||
**提示词优化模型**
|
||||
|
||||
将简单提示词扩展为详细的电影级提示词,包含场景设置、镜头运动、光影效果、分镜描述等。
|
||||
|
||||
| 模型 | 扩展级别 | 时长 | 说明 |
|
||||
|------|---------|------|------|
|
||||
| `prompt-enhance-short-10s` | 简短 | 10秒 | 生成简洁的增强提示词 |
|
||||
| `prompt-enhance-short-15s` | 简短 | 15秒 | 生成简洁的增强提示词 |
|
||||
| `prompt-enhance-short-20s` | 简短 | 20秒 | 生成简洁的增强提示词 |
|
||||
| `prompt-enhance-medium-10s` | 中等 | 10秒 | 生成中等长度的增强提示词 |
|
||||
| `prompt-enhance-medium-15s` | 中等 | 15秒 | 生成中等长度的增强提示词 |
|
||||
| `prompt-enhance-medium-20s` | 中等 | 20秒 | 生成中等长度的增强提示词 |
|
||||
| `prompt-enhance-long-10s` | 详细 | 10秒 | 生成详细的增强提示词 |
|
||||
| `prompt-enhance-long-15s` | 详细 | 15秒 | 生成详细的增强提示词 |
|
||||
| `prompt-enhance-long-20s` | 详细 | 20秒 | 生成详细的增强提示词 |
|
||||
|
||||
**特点:**
|
||||
- 支持流式和非流式响应
|
||||
- 自动生成包含PRIMARY、SETTING、LOOK、CAMERA、LIGHT等专业电影术语的提示词
|
||||
- 包含详细的分镜描述(时间轴、镜头运动、焦点、光影)
|
||||
- 可直接用于视频生成模型
|
||||
|
||||
#### 请求示例
|
||||
|
||||
**文生图**
|
||||
@@ -224,6 +247,42 @@ curl -X POST "http://localhost:8000/v1/chat/completions" \
|
||||
}'
|
||||
```
|
||||
|
||||
**提示词优化(流式)**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
||||
-H "Authorization: Bearer han1234" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "prompt-enhance-medium-10s",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "猫猫"
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
**提示词优化(非流式)**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
||||
-H "Authorization: Bearer han1234" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "prompt-enhance-long-15s",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "一只橘猫在窗台玩耍"
|
||||
}
|
||||
],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
**文生视频**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -11,4 +11,5 @@ pydantic-settings==2.7.0
|
||||
tomli==2.2.1
|
||||
toml
|
||||
faker==24.0.0
|
||||
python-dateutil==2.8.2
|
||||
python-dateutil==2.8.2
|
||||
APScheduler==3.10.4
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from pydantic import BaseModel
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from ..core.auth import AuthManager
|
||||
from ..core.config import config
|
||||
from ..services.token_manager import TokenManager
|
||||
@@ -22,18 +23,20 @@ proxy_manager: ProxyManager = None
|
||||
db: Database = None
|
||||
generation_handler = None
|
||||
concurrency_manager: ConcurrencyManager = None
|
||||
scheduler = None
|
||||
|
||||
# Store active admin tokens (in production, use Redis or database)
|
||||
active_admin_tokens = set()
|
||||
|
||||
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None, cm: ConcurrencyManager = None):
|
||||
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None, cm: ConcurrencyManager = None, sched=None):
|
||||
"""Set dependencies"""
|
||||
global token_manager, proxy_manager, db, generation_handler, concurrency_manager
|
||||
global token_manager, proxy_manager, db, generation_handler, concurrency_manager, scheduler
|
||||
token_manager = tm
|
||||
proxy_manager = pm
|
||||
db = database
|
||||
generation_handler = gh
|
||||
concurrency_manager = cm
|
||||
scheduler = sched
|
||||
|
||||
def verify_admin_token(authorization: str = Header(None)):
|
||||
"""Verify admin token from Authorization header"""
|
||||
@@ -69,8 +72,8 @@ class AddTokenRequest(BaseModel):
|
||||
remark: Optional[str] = None
|
||||
image_enabled: bool = True # Enable image generation
|
||||
video_enabled: bool = True # Enable video generation
|
||||
image_concurrency: int = -1 # Image concurrency limit (-1 for no limit)
|
||||
video_concurrency: int = -1 # Video concurrency limit (-1 for no limit)
|
||||
image_concurrency: int = 1 # Image concurrency limit (default: 1)
|
||||
video_concurrency: int = 3 # Video concurrency limit (default: 3)
|
||||
|
||||
class ST2ATRequest(BaseModel):
|
||||
st: str # Session Token
|
||||
@@ -1093,6 +1096,24 @@ async def update_at_auto_refresh_enabled(
|
||||
# Update database
|
||||
await db.update_token_refresh_config(enabled)
|
||||
|
||||
# Dynamically start or stop scheduler
|
||||
if scheduler:
|
||||
if enabled:
|
||||
# Start scheduler if not already running
|
||||
if not scheduler.running:
|
||||
scheduler.add_job(
|
||||
token_manager.batch_refresh_all_tokens,
|
||||
CronTrigger(hour=0, minute=0),
|
||||
id='batch_refresh_tokens',
|
||||
name='Batch refresh all tokens',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.start()
|
||||
else:
|
||||
# Stop scheduler if running
|
||||
if scheduler.running:
|
||||
scheduler.remove_job('batch_refresh_tokens')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"AT auto refresh {'enabled' if enabled else 'disabled'} successfully",
|
||||
@@ -1101,6 +1122,43 @@ async def update_at_auto_refresh_enabled(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
|
||||
|
||||
# Task management endpoints
|
||||
@router.post("/api/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
||||
"""Cancel a running task"""
|
||||
try:
|
||||
# Get task from database
|
||||
task = await db.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# Check if task is still processing
|
||||
if task.status not in ["processing"]:
|
||||
return {"success": False, "message": f"任务状态为 {task.status},无法取消"}
|
||||
|
||||
# Update task status to failed
|
||||
await db.update_task(task_id, "failed", 0, error_message="用户手动取消任务")
|
||||
|
||||
# Update request log if exists
|
||||
logs = await db.get_recent_logs(limit=1000)
|
||||
for log in logs:
|
||||
if log.get("task_id") == task_id and log.get("status_code") == -1:
|
||||
import time
|
||||
duration = time.time() - (log.get("created_at").timestamp() if log.get("created_at") else time.time())
|
||||
await db.update_request_log(
|
||||
log.get("id"),
|
||||
response_body='{"error": "用户手动取消任务"}',
|
||||
status_code=499,
|
||||
duration=duration
|
||||
)
|
||||
break
|
||||
|
||||
return {"success": True, "message": "任务已取消"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"取消任务失败: {str(e)}")
|
||||
|
||||
# Debug logs download endpoint
|
||||
@router.get("/api/admin/logs/download")
|
||||
async def download_debug_logs(token: str = Depends(verify_admin_token)):
|
||||
|
||||
@@ -46,21 +46,23 @@ def _extract_remix_id(text: str) -> str:
|
||||
async def list_models(api_key: str = Depends(verify_api_key_header)):
|
||||
"""List available models"""
|
||||
models = []
|
||||
|
||||
|
||||
for model_id, config in MODEL_CONFIG.items():
|
||||
description = f"{config['type'].capitalize()} generation"
|
||||
if config['type'] == 'image':
|
||||
description += f" - {config['width']}x{config['height']}"
|
||||
else:
|
||||
elif config['type'] == 'video':
|
||||
description += f" - {config['orientation']}"
|
||||
|
||||
elif config['type'] == 'prompt_enhance':
|
||||
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
|
||||
|
||||
models.append({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"owned_by": "sora2api",
|
||||
"description": description
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"data": models
|
||||
|
||||
24
src/main.py
24
src/main.py
@@ -5,6 +5,9 @@ from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from datetime import datetime
|
||||
|
||||
# Import modules
|
||||
from .core.config import config
|
||||
@@ -18,6 +21,9 @@ from .services.concurrency_manager import ConcurrencyManager
|
||||
from .api import routes as api_routes
|
||||
from .api import admin as admin_routes
|
||||
|
||||
# Initialize scheduler (uses system local timezone by default)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Sora2API",
|
||||
@@ -45,7 +51,7 @@ generation_handler = GenerationHandler(sora_client, token_manager, load_balancer
|
||||
|
||||
# Set dependencies for route modules
|
||||
api_routes.set_generation_handler(generation_handler)
|
||||
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler, concurrency_manager)
|
||||
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler, concurrency_manager, scheduler)
|
||||
|
||||
# Include routers
|
||||
app.include_router(api_routes.router)
|
||||
@@ -141,10 +147,26 @@ async def startup_event():
|
||||
# Start file cache cleanup task
|
||||
await generation_handler.file_cache.start_cleanup_task()
|
||||
|
||||
# Start token refresh scheduler if enabled
|
||||
if token_refresh_config.at_auto_refresh_enabled:
|
||||
scheduler.add_job(
|
||||
token_manager.batch_refresh_all_tokens,
|
||||
CronTrigger(hour=0, minute=0), # Every day at 00:00 (system local timezone)
|
||||
id='batch_refresh_tokens',
|
||||
name='Batch refresh all tokens',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.start()
|
||||
print("✓ Token auto-refresh scheduler started (daily at 00:00)")
|
||||
else:
|
||||
print("⊘ Token auto-refresh is disabled")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Cleanup on shutdown"""
|
||||
await generation_handler.file_cache.stop_cleanup_task()
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
|
||||
@@ -154,6 +154,52 @@ MODEL_CONFIG = {
|
||||
"model": "sy_ore",
|
||||
"size": "large",
|
||||
"require_pro": True
|
||||
},
|
||||
# Prompt enhancement models
|
||||
"prompt-enhance-short-10s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "short",
|
||||
"duration_s": 10
|
||||
},
|
||||
"prompt-enhance-short-15s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "short",
|
||||
"duration_s": 15
|
||||
},
|
||||
"prompt-enhance-short-20s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "short",
|
||||
"duration_s": 20
|
||||
},
|
||||
"prompt-enhance-medium-10s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "medium",
|
||||
"duration_s": 10
|
||||
},
|
||||
"prompt-enhance-medium-15s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "medium",
|
||||
"duration_s": 15
|
||||
},
|
||||
"prompt-enhance-medium-20s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "medium",
|
||||
"duration_s": 20
|
||||
},
|
||||
"prompt-enhance-long-10s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "long",
|
||||
"duration_s": 10
|
||||
},
|
||||
"prompt-enhance-long-15s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "long",
|
||||
"duration_s": 15
|
||||
},
|
||||
"prompt-enhance-long-20s": {
|
||||
"type": "prompt_enhance",
|
||||
"expansion_level": "long",
|
||||
"duration_s": 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +402,13 @@ class GenerationHandler:
|
||||
model_config = MODEL_CONFIG[model]
|
||||
is_video = model_config["type"] == "video"
|
||||
is_image = model_config["type"] == "image"
|
||||
is_prompt_enhance = model_config["type"] == "prompt_enhance"
|
||||
|
||||
# Handle prompt enhancement
|
||||
if is_prompt_enhance:
|
||||
async for chunk in self._handle_prompt_enhance(prompt, model_config, stream):
|
||||
yield chunk
|
||||
return
|
||||
|
||||
# Non-streaming mode: only check availability
|
||||
if not stream:
|
||||
@@ -1275,6 +1328,60 @@ class GenerationHandler:
|
||||
print(f"Failed to log request: {e}")
|
||||
return None
|
||||
|
||||
# ==================== Prompt Enhancement Handler ====================
|
||||
|
||||
async def _handle_prompt_enhance(self, prompt: str, model_config: Dict, stream: bool) -> AsyncGenerator[str, None]:
|
||||
"""Handle prompt enhancement request
|
||||
|
||||
Args:
|
||||
prompt: Original prompt to enhance
|
||||
model_config: Model configuration
|
||||
stream: Whether to stream response
|
||||
"""
|
||||
expansion_level = model_config["expansion_level"]
|
||||
duration_s = model_config["duration_s"]
|
||||
|
||||
# Select token
|
||||
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
||||
if not token_obj:
|
||||
error_msg = "No available tokens for prompt enhancement"
|
||||
if stream:
|
||||
yield self._format_stream_chunk(reasoning_content=f"**Error:** {error_msg}", is_first=True)
|
||||
yield self._format_stream_chunk(finish_reason="STOP")
|
||||
else:
|
||||
yield self._format_non_stream_response(error_msg)
|
||||
return
|
||||
|
||||
try:
|
||||
# Call enhance_prompt API
|
||||
enhanced_prompt = await self.sora_client.enhance_prompt(
|
||||
prompt=prompt,
|
||||
token=token_obj.token,
|
||||
expansion_level=expansion_level,
|
||||
duration_s=duration_s,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
|
||||
if stream:
|
||||
# Stream response
|
||||
yield self._format_stream_chunk(
|
||||
content=enhanced_prompt,
|
||||
is_first=True
|
||||
)
|
||||
yield self._format_stream_chunk(finish_reason="STOP")
|
||||
else:
|
||||
# Non-stream response
|
||||
yield self._format_non_stream_response(enhanced_prompt)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Prompt enhancement failed: {str(e)}"
|
||||
debug_logger.log_error(error_msg)
|
||||
if stream:
|
||||
yield self._format_stream_chunk(content=f"Error: {error_msg}", is_first=True)
|
||||
yield self._format_stream_chunk(finish_reason="STOP")
|
||||
else:
|
||||
yield self._format_non_stream_response(error_msg)
|
||||
|
||||
# ==================== Character Creation and Remix Handlers ====================
|
||||
|
||||
async def _handle_character_creation_only(self, video_data, model_config: Dict) -> AsyncGenerator[str, None]:
|
||||
|
||||
@@ -29,29 +29,6 @@ class LoadBalancer:
|
||||
Returns:
|
||||
Selected token or None if no available tokens
|
||||
"""
|
||||
# Try to auto-refresh tokens expiring within 24 hours if enabled
|
||||
if config.at_auto_refresh_enabled:
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] 🔄 自动刷新功能已启用,开始检查Token过期时间...")
|
||||
all_tokens = await self.token_manager.get_all_tokens()
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] 📊 总Token数: {len(all_tokens)}")
|
||||
|
||||
refresh_count = 0
|
||||
for token in all_tokens:
|
||||
if token.is_active and token.expiry_time:
|
||||
from datetime import datetime
|
||||
time_until_expiry = token.expiry_time - datetime.now()
|
||||
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
||||
# Refresh if expiry is within 24 hours
|
||||
if hours_until_expiry <= 24:
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] 🔔 Token {token.id} ({token.email}) 需要刷新,剩余时间: {hours_until_expiry:.2f} 小时")
|
||||
refresh_count += 1
|
||||
await self.token_manager.auto_refresh_expiring_token(token.id)
|
||||
|
||||
if refresh_count == 0:
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] ✅ 所有Token都无需刷新")
|
||||
else:
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] ✅ 刷新检查完成,共检查 {refresh_count} 个Token")
|
||||
|
||||
active_tokens = await self.token_manager.get_active_tokens()
|
||||
|
||||
if not active_tokens:
|
||||
|
||||
@@ -934,3 +934,26 @@ class SoraClient:
|
||||
|
||||
result = await self._make_request("POST", "/nf/create/storyboard", token, json_data=json_data, add_sentinel_token=True)
|
||||
return result.get("id")
|
||||
|
||||
async def enhance_prompt(self, prompt: str, token: str, expansion_level: str = "medium",
|
||||
duration_s: int = 10, token_id: Optional[int] = None) -> str:
|
||||
"""Enhance prompt using Sora's prompt enhancement API
|
||||
|
||||
Args:
|
||||
prompt: Original prompt to enhance
|
||||
token: Access token
|
||||
expansion_level: Expansion level (medium/long)
|
||||
duration_s: Duration in seconds (10/15/20)
|
||||
token_id: Token ID for getting token-specific proxy (optional)
|
||||
|
||||
Returns:
|
||||
Enhanced prompt text
|
||||
"""
|
||||
json_data = {
|
||||
"prompt": prompt,
|
||||
"expansion_level": expansion_level,
|
||||
"duration_s": duration_s
|
||||
}
|
||||
|
||||
result = await self._make_request("POST", "/editor/enhance_prompt", token, json_data=json_data, token_id=token_id)
|
||||
return result.get("enhanced_prompt", "")
|
||||
|
||||
@@ -1185,7 +1185,7 @@ class TokenManager:
|
||||
if token_data.st:
|
||||
try:
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 ST 刷新...")
|
||||
result = await self.st_to_at(token_data.st)
|
||||
result = await self.st_to_at(token_data.st, proxy_url=token_data.proxy_url)
|
||||
new_at = result.get("access_token")
|
||||
new_st = token_data.st # ST refresh doesn't return new ST, so keep the old one
|
||||
refresh_method = "ST"
|
||||
@@ -1198,7 +1198,7 @@ class TokenManager:
|
||||
if not new_at and token_data.rt:
|
||||
try:
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 RT 刷新...")
|
||||
result = await self.rt_to_at(token_data.rt, client_id=token_data.client_id)
|
||||
result = await self.rt_to_at(token_data.rt, client_id=token_data.client_id, proxy_url=token_data.proxy_url)
|
||||
new_at = result.get("access_token")
|
||||
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
|
||||
refresh_method = "RT"
|
||||
@@ -1225,18 +1225,80 @@ class TokenManager:
|
||||
|
||||
# 📍 Step 9: 检查刷新后的过期时间
|
||||
if new_hours_until_expiry < 0:
|
||||
# 刷新后仍然过期,禁用Token
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 刷新后仍然过期(剩余时间: {new_hours_until_expiry:.2f} 小时),已禁用")
|
||||
await self.disable_token(token_id)
|
||||
# 刷新后仍然过期,标记为已失效并禁用Token
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 刷新后仍然过期(剩余时间: {new_hours_until_expiry:.2f} 小时),标记为已失效并禁用")
|
||||
await self.db.mark_token_expired(token_id)
|
||||
await self.db.update_token_status(token_id, False)
|
||||
return False
|
||||
|
||||
return True
|
||||
else:
|
||||
# 刷新失败: 禁用Token
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 🚫 Token {token_id}: 无法刷新(无有效的 ST 或 RT),已禁用")
|
||||
await self.disable_token(token_id)
|
||||
# 刷新失败: 标记为已失效并禁用Token
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 🚫 Token {token_id}: 无法刷新(无有效的 ST 或 RT),标记为已失效并禁用")
|
||||
await self.db.mark_token_expired(token_id)
|
||||
await self.db.update_token_status(token_id, False)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 自动刷新异常 - {str(e)}")
|
||||
return False
|
||||
|
||||
async def batch_refresh_all_tokens(self) -> dict:
|
||||
"""
|
||||
Batch refresh all tokens (called by scheduled task at midnight)
|
||||
|
||||
Returns:
|
||||
dict with success/failed/skipped counts
|
||||
"""
|
||||
debug_logger.log_info("[BATCH_REFRESH] 🔄 开始批量刷新所有Token...")
|
||||
|
||||
# Get all tokens
|
||||
all_tokens = await self.db.get_all_tokens()
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for token in all_tokens:
|
||||
# Skip tokens without ST or RT
|
||||
if not token.st and not token.rt:
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 无ST或RT,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Skip tokens without expiry time
|
||||
if not token.expiry_time:
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 无过期时间,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Check if token needs refresh (expiry within 24 hours)
|
||||
time_until_expiry = token.expiry_time - datetime.now()
|
||||
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
||||
|
||||
if hours_until_expiry > 24:
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 剩余时间 {hours_until_expiry:.2f}h > 24h,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Try to refresh
|
||||
try:
|
||||
result = await self.auto_refresh_expiring_token(token.id)
|
||||
if result:
|
||||
success_count += 1
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ✅ Token {token.id} ({token.email}): 刷新成功")
|
||||
else:
|
||||
failed_count += 1
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ❌ Token {token.id} ({token.email}): 刷新失败")
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ❌ Token {token.id} ({token.email}): 刷新异常 - {str(e)}")
|
||||
|
||||
debug_logger.log_info(f"[BATCH_REFRESH] ✅ 批量刷新完成: 成功 {success_count}, 失败 {failed_count}, 跳过 {skipped_count}")
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"failed": failed_count,
|
||||
"skipped": skipped_count,
|
||||
"total": len(all_tokens)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分页控件 -->
|
||||
<div id="paginationContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,9 +401,10 @@
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-40">Token邮箱</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-20">状态码</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">进度</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-24">耗时(秒)</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-44">时间</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-20">详情</th>
|
||||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTableBody" class="divide-y divide-border">
|
||||
@@ -517,7 +520,7 @@
|
||||
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
|
||||
<span class="text-sm font-medium">启用图片生成</span>
|
||||
</label>
|
||||
<input type="number" id="addTokenImageConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
||||
<input type="number" id="addTokenImageConcurrency" value="1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -526,7 +529,7 @@
|
||||
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
|
||||
<span class="text-sm font-medium">启用视频生成</span>
|
||||
</label>
|
||||
<input type="number" id="addTokenVideoConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
||||
<input type="number" id="addTokenVideoConcurrency" value="3" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -773,7 +776,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTokens=[];
|
||||
let allTokens=[],currentPage=1,pageSize=20;
|
||||
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},
|
||||
@@ -785,10 +788,13 @@
|
||||
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 tb=$('tokenTableBody');tb.innerHTML=allTokens.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('')},
|
||||
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()},
|
||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||
changePage=(page)=>{currentPage=page;renderTokens()},
|
||||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
||||
renderPagination=()=>{const totalPages=Math.ceil(allTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+allTokens.length+' 条</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
|
||||
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
||||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('addRTRefreshHint').classList.add('hidden')},
|
||||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='1';$('addTokenVideoConcurrency').value='3';$('addRTRefreshHint').classList.add('hidden')},
|
||||
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenClientId').value=token.client_id||'';$('editTokenProxyUrl').value=token.proxy_url||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
|
||||
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenProxyUrl').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')},
|
||||
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),proxyUrl=$('editTokenProxyUrl').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
||||
@@ -835,11 +841,12 @@
|
||||
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
||||
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
|
||||
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
|
||||
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===-1?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code===-1?'生成中':l.status_code}</span></td><td class="py-2.5 px-3">${l.duration===-1?'生成中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button></td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
|
||||
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></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">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
|
||||
refreshLogs=async()=>{await loadLogs()},
|
||||
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?'生成中':log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
|
||||
closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
|
||||
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
|
||||
cancelTask=async(taskId)=>{if(!confirm('确定要终止这个任务吗?'))return;try{const r=await apiRequest(`/api/tasks/${taskId}/cancel`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast('任务已终止','success');await loadLogs()}else{showToast('终止失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('终止失败: '+e.message,'error')}},
|
||||
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)},
|
||||
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
||||
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
|
||||
|
||||
Reference in New Issue
Block a user