Compare commits

...

2 Commits

11 changed files with 667 additions and 217 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -144,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)
@@ -314,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 = {
@@ -325,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))
@@ -349,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 = []
@@ -382,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"""
@@ -798,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)):
@@ -1093,6 +1094,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 +1120,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)):

View File

@@ -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

View File

@@ -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:

View File

@@ -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(

View File

@@ -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:
@@ -438,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:
@@ -520,7 +586,7 @@ class GenerationHandler:
media_id=media_id,
token_id=token_obj.id
)
# Save task to database
task = Task(
task_id=task_id,
@@ -532,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)
@@ -734,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):
@@ -1275,6 +1337,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]:

View File

@@ -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:

View File

@@ -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", "")

View File

@@ -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)
}

View File

@@ -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>
@@ -171,6 +282,8 @@
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div id="paginationContainer"></div>
</div>
</div>
@@ -399,9 +512,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 +631,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 +640,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>
@@ -647,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">
@@ -772,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=[];
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},
@@ -781,14 +899,15 @@
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 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.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()},
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')}},
@@ -801,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')}},
@@ -835,11 +956,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')}},