Compare commits

...

3 Commits

6 changed files with 326 additions and 106 deletions

View File

@@ -1,7 +1,9 @@
"""Admin routes - Management endpoints"""
from fastapi import APIRouter, HTTPException, Depends, Header
from fastapi.responses import FileResponse
from typing import List, Optional
from datetime import datetime
from pathlib import Path
import secrets
from pydantic import BaseModel
from ..core.auth import AuthManager
@@ -93,10 +95,11 @@ class UpdateTokenRequest(BaseModel):
video_concurrency: Optional[int] = None # Video concurrency limit
class ImportTokenItem(BaseModel):
email: str # Email (primary key)
access_token: str # Access Token (AT)
email: str # Email (primary key, required)
access_token: Optional[str] = None # Access Token (AT, optional for st/rt modes)
session_token: Optional[str] = None # Session Token (ST)
refresh_token: Optional[str] = None # Refresh Token (RT)
client_id: Optional[str] = None # Client ID (optional, for compatibility)
proxy_url: Optional[str] = None # Proxy URL (optional, for compatibility)
remark: Optional[str] = None # Remark (optional, for compatibility)
is_active: bool = True # Active status
@@ -107,6 +110,7 @@ class ImportTokenItem(BaseModel):
class ImportTokensRequest(BaseModel):
tokens: List[ImportTokenItem]
mode: str = "at" # Import mode: offline/at/st/rt
class UpdateAdminConfigRequest(BaseModel):
error_ban_threshold: int
@@ -346,7 +350,8 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
@router.post("/api/tokens/import")
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
"""Import tokens in append mode (update if exists, add if not)"""
"""Import tokens with different modes: offline/at/st/rt"""
mode = request.mode # offline/at/st/rt
added_count = 0
updated_count = 0
failed_count = 0
@@ -354,22 +359,74 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
for import_item in request.tokens:
try:
# Check if token with this email already exists
# Step 1: Get or convert access_token based on mode
access_token = None
skip_status = False
if mode == "offline":
# Offline mode: use provided AT, skip status update
if not import_item.access_token:
raise ValueError("离线导入模式需要提供 access_token")
access_token = import_item.access_token
skip_status = True
elif mode == "at":
# AT mode: use provided AT, update status (current logic)
if not import_item.access_token:
raise ValueError("AT导入模式需要提供 access_token")
access_token = import_item.access_token
skip_status = False
elif mode == "st":
# ST mode: convert ST to AT, update status
if not import_item.session_token:
raise ValueError("ST导入模式需要提供 session_token")
# Convert ST to AT
st_result = await token_manager.st_to_at(import_item.session_token)
access_token = st_result["access_token"]
# Update email if API returned it
if "email" in st_result and st_result["email"]:
import_item.email = st_result["email"]
skip_status = False
elif mode == "rt":
# RT mode: convert RT to AT, update status
if not import_item.refresh_token:
raise ValueError("RT导入模式需要提供 refresh_token")
# Convert RT to AT
rt_result = await token_manager.rt_to_at(
import_item.refresh_token,
client_id=import_item.client_id
)
access_token = rt_result["access_token"]
# Update RT if API returned new one
if "refresh_token" in rt_result and rt_result["refresh_token"]:
import_item.refresh_token = rt_result["refresh_token"]
# Update email if API returned it
if "email" in rt_result and rt_result["email"]:
import_item.email = rt_result["email"]
skip_status = False
else:
raise ValueError(f"不支持的导入模式: {mode}")
# Step 2: Check if token with this email already exists
existing_token = await db.get_token_by_email(import_item.email)
if existing_token:
# Update existing token
await token_manager.update_token(
token_id=existing_token.id,
token=import_item.access_token,
token=access_token,
st=import_item.session_token,
rt=import_item.refresh_token,
client_id=import_item.client_id,
proxy_url=import_item.proxy_url,
remark=import_item.remark,
image_enabled=import_item.image_enabled,
video_enabled=import_item.video_enabled,
image_concurrency=import_item.image_concurrency,
video_concurrency=import_item.video_concurrency
video_concurrency=import_item.video_concurrency,
skip_status_update=skip_status
)
# Update active status
await token_manager.update_token_status(existing_token.id, import_item.is_active)
@@ -389,16 +446,19 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
else:
# Add new token
new_token = await token_manager.add_token(
token_value=import_item.access_token,
token_value=access_token,
st=import_item.session_token,
rt=import_item.refresh_token,
client_id=import_item.client_id,
proxy_url=import_item.proxy_url,
remark=import_item.remark,
update_if_exists=False,
image_enabled=import_item.image_enabled,
video_enabled=import_item.video_enabled,
image_concurrency=import_item.image_concurrency,
video_concurrency=import_item.video_concurrency
video_concurrency=import_item.video_concurrency,
skip_status_update=skip_status,
email=import_item.email # Pass email for offline mode
)
# Set active status
if not import_item.is_active:
@@ -427,7 +487,7 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
return {
"success": True,
"message": f"Import completed: {added_count} added, {updated_count} updated, {failed_count} failed",
"message": f"Import completed ({mode} mode): {added_count} added, {updated_count} updated, {failed_count} failed",
"added": added_count,
"updated": updated_count,
"failed": failed_count,
@@ -963,3 +1023,18 @@ 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)}")
# Debug logs download endpoint
@router.get("/api/admin/logs/download")
async def download_debug_logs(token: str = Depends(verify_admin_token)):
"""Download debug logs file (logs.txt)"""
log_file = Path("logs.txt")
if not log_file.exists():
raise HTTPException(status_code=404, detail="日志文件不存在")
return FileResponse(
path=str(log_file),
filename="logs.txt",
media_type="text/plain"
)

View File

@@ -198,6 +198,7 @@ class Database:
("video_concurrency", "INTEGER DEFAULT -1"),
("client_id", "TEXT"),
("proxy_url", "TEXT"),
("is_expired", "BOOLEAN DEFAULT 0"),
]
for col_name, col_type in columns_to_add:
@@ -310,7 +311,8 @@ class Database:
image_enabled BOOLEAN DEFAULT 1,
video_enabled BOOLEAN DEFAULT 1,
image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1
video_concurrency INTEGER DEFAULT -1,
is_expired BOOLEAN DEFAULT 0
)
""")
@@ -570,7 +572,23 @@ class Database:
UPDATE tokens SET is_active = ? WHERE id = ?
""", (is_active, token_id))
await db.commit()
async def mark_token_expired(self, token_id: int):
"""Mark token as expired and disable it"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
""", (token_id,))
await db.commit()
async def clear_token_expired(self, token_id: int):
"""Clear token expired flag"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 0 WHERE id = ?
""", (token_id,))
await db.commit()
async def update_token_sora2(self, token_id: int, supported: bool, invite_code: Optional[str] = None,
redeemed_count: int = 0, total_count: int = 0, remaining_count: int = 0):
"""Update token Sora2 support info"""

View File

@@ -15,17 +15,21 @@ class DebugLogger:
def _setup_logger(self):
"""Setup file logger"""
# Clear log file on startup
if self.log_file.exists():
self.log_file.unlink()
# Create logger
self.logger = logging.getLogger("debug_logger")
self.logger.setLevel(logging.DEBUG)
# Remove existing handlers
self.logger.handlers.clear()
# Create file handler
file_handler = logging.FileHandler(
self.log_file,
mode='a',
self.log_file,
mode='a',
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)

View File

@@ -38,6 +38,8 @@ class Token(BaseModel):
# 并发限制
image_concurrency: int = -1 # 图片并发数限制,-1表示不限制
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
# 过期标记
is_expired: bool = False # Token是否已过期401 token_invalidated
class TokenStats(BaseModel):
"""Token statistics"""

View File

@@ -86,6 +86,15 @@ class TokenManager:
)
if response.status_code != 200:
# Check for token_invalidated error
if response.status_code == 401:
try:
error_data = response.json()
error_code = error_data.get("error", {}).get("code", "")
if error_code == "token_invalidated":
raise ValueError(f"401 token_invalidated: Token has been invalidated")
except (ValueError, KeyError):
pass
raise ValueError(f"Failed to get user info: {response.status_code}")
return response.json()
@@ -649,7 +658,9 @@ class TokenManager:
image_enabled: bool = True,
video_enabled: bool = True,
image_concurrency: int = -1,
video_concurrency: int = -1) -> Token:
video_concurrency: int = -1,
skip_status_update: bool = False,
email: Optional[str] = None) -> Token:
"""Add a new Access Token to database
Args:
@@ -690,101 +701,112 @@ class TokenManager:
if "https://api.openai.com/profile" in decoded:
jwt_email = decoded["https://api.openai.com/profile"].get("email")
# Get user info from Sora API
try:
user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
email = user_info.get("email", jwt_email or "")
name = user_info.get("name") or ""
except Exception as e:
# If API call fails, use JWT data
email = jwt_email or ""
name = email.split("@")[0] if email else ""
# Get subscription info from Sora API
# Initialize variables
name = ""
plan_type = None
plan_title = None
subscription_end = None
try:
sub_info = await self.get_subscription_info(token_value, proxy_url=proxy_url)
plan_type = sub_info.get("plan_type")
plan_title = sub_info.get("plan_title")
# Parse subscription end time
if sub_info.get("subscription_end"):
from dateutil import parser
subscription_end = parser.parse(sub_info["subscription_end"])
except Exception as e:
error_msg = str(e)
# Re-raise if it's a critical error (token expired)
if "Token已过期" in error_msg:
raise
# If API call fails, subscription info will be None
print(f"Failed to get subscription info: {e}")
# Get Sora2 invite code
sora2_supported = None
sora2_invite_code = None
sora2_redeemed_count = 0
sora2_total_count = 0
sora2_remaining_count = 0
try:
sora2_info = await self.get_sora2_invite_code(token_value, proxy_url=proxy_url)
sora2_supported = sora2_info.get("supported", False)
sora2_invite_code = sora2_info.get("invite_code")
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
sora2_total_count = sora2_info.get("total_count", 0)
sora2_redeemed_count = -1
sora2_total_count = -1
sora2_remaining_count = -1
# If Sora2 is supported, get remaining count
if sora2_supported:
try:
remaining_info = await self.get_sora2_remaining_count(token_value, proxy_url=proxy_url)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
print(f"✅ Sora2剩余次数: {sora2_remaining_count}")
except Exception as e:
print(f"Failed to get Sora2 remaining count: {e}")
except Exception as e:
error_msg = str(e)
# Re-raise if it's a critical error (unsupported country)
if "Sora在您的国家/地区不可用" in error_msg:
raise
# If API call fails, Sora2 info will be None
print(f"Failed to get Sora2 info: {e}")
if skip_status_update:
# Offline mode: use provided email or JWT email, skip API calls
email = email or jwt_email or ""
name = email.split("@")[0] if email else ""
else:
# Normal mode: get user info from Sora API
try:
user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
email = user_info.get("email", jwt_email or "")
name = user_info.get("name") or ""
except Exception as e:
# If API call fails, use JWT data
email = jwt_email or ""
name = email.split("@")[0] if email else ""
# Check and set username if needed
try:
# Get fresh user info to check username
user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
username = user_info.get("username")
# Get subscription info from Sora API
try:
sub_info = await self.get_subscription_info(token_value, proxy_url=proxy_url)
plan_type = sub_info.get("plan_type")
plan_title = sub_info.get("plan_title")
# Parse subscription end time
if sub_info.get("subscription_end"):
from dateutil import parser
subscription_end = parser.parse(sub_info["subscription_end"])
except Exception as e:
error_msg = str(e)
# Re-raise if it's a critical error (token expired)
if "Token已过期" in error_msg:
raise
# If API call fails, subscription info will be None
print(f"Failed to get subscription info: {e}")
# If username is null, need to set one
if username is None:
print(f"⚠️ 检测到用户名为null需要设置用户名")
# Get Sora2 invite code
sora2_redeemed_count = 0
sora2_total_count = 0
sora2_remaining_count = 0
try:
sora2_info = await self.get_sora2_invite_code(token_value, proxy_url=proxy_url)
sora2_supported = sora2_info.get("supported", False)
sora2_invite_code = sora2_info.get("invite_code")
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
sora2_total_count = sora2_info.get("total_count", 0)
# Generate random username
max_attempts = 5
for attempt in range(max_attempts):
generated_username = self._generate_random_username()
print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}")
# If Sora2 is supported, get remaining count
if sora2_supported:
try:
remaining_info = await self.get_sora2_remaining_count(token_value, proxy_url=proxy_url)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
print(f"✅ Sora2剩余次数: {sora2_remaining_count}")
except Exception as e:
print(f"Failed to get Sora2 remaining count: {e}")
except Exception as e:
error_msg = str(e)
# Re-raise if it's a critical error (unsupported country)
if "Sora在您的国家/地区不可用" in error_msg:
raise
# If API call fails, Sora2 info will be None
print(f"Failed to get Sora2 info: {e}")
# Check if username is available
if await self.check_username_available(token_value, generated_username):
# Set the username
try:
await self.set_username(token_value, generated_username)
print(f"✅ 用户名设置成功: {generated_username}")
break
except Exception as e:
print(f"❌ 用户名设置失败: {e}")
# Check and set username if needed
try:
# Get fresh user info to check username
user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
username = user_info.get("username")
# If username is null, need to set one
if username is None:
print(f"⚠️ 检测到用户名为null需要设置用户名")
# Generate random username
max_attempts = 5
for attempt in range(max_attempts):
generated_username = self._generate_random_username()
print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}")
# Check if username is available
if await self.check_username_available(token_value, generated_username):
# Set the username
try:
await self.set_username(token_value, generated_username)
print(f"✅ 用户名设置成功: {generated_username}")
break
except Exception as e:
print(f"❌ 用户名设置失败: {e}")
if attempt == max_attempts - 1:
print(f"⚠️ 达到最大尝试次数,跳过用户名设置")
else:
print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个")
if attempt == max_attempts - 1:
print(f"⚠️ 达到最大尝试次数,跳过用户名设置")
else:
print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个")
if attempt == max_attempts - 1:
print(f"⚠️ 达到最大尝试次数,跳过用户名设置")
else:
print(f"✅ 用户名已设置: {username}")
except Exception as e:
print(f"⚠️ 用户名检查/设置过程中出错: {e}")
else:
print(f" 用户名已设置: {username}")
except Exception as e:
print(f"⚠️ 用户名检查/设置过程中出错: {e}")
# Create token object
token = Token(
@@ -885,7 +907,8 @@ class TokenManager:
image_enabled: Optional[bool] = None,
video_enabled: Optional[bool] = None,
image_concurrency: Optional[int] = None,
video_concurrency: Optional[int] = None):
video_concurrency: Optional[int] = None,
skip_status_update: bool = False):
"""Update token (AT, ST, RT, client_id, proxy_url, remark, image_enabled, video_enabled, concurrency limits)"""
# If token (AT) is updated, decode JWT to get new expiry time
expiry_time = None
@@ -900,6 +923,17 @@ class TokenManager:
image_enabled=image_enabled, video_enabled=video_enabled,
image_concurrency=image_concurrency, video_concurrency=video_concurrency)
# If token (AT) is updated and not in offline mode, test it and clear expired flag if valid
if token and not skip_status_update:
try:
test_result = await self.test_token(token_id)
if test_result.get("valid"):
# Token is valid, enable it and clear expired flag
await self.db.update_token_status(token_id, True)
await self.db.clear_token_expired(token_id)
except Exception:
pass # Ignore test errors during update
async def get_active_tokens(self) -> List[Token]:
"""Get all active tokens (not cooled down)"""
return await self.db.get_active_tokens()
@@ -917,13 +951,15 @@ class TokenManager:
await self.db.update_token_status(token_id, True)
# Reset error count when enabling (in token_stats table)
await self.db.reset_error_count(token_id)
# Clear expired flag when enabling
await self.db.clear_token_expired(token_id)
async def disable_token(self, token_id: int):
"""Disable a token"""
await self.db.update_token_status(token_id, False)
async def test_token(self, token_id: int) -> dict:
"""Test if a token is valid by calling Sora API and refresh Sora2 info"""
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
# Get token from database
token_data = await self.db.get_token(token_id)
if not token_data:
@@ -933,6 +969,21 @@ class TokenManager:
# Try to get user info from Sora API
user_info = await self.get_user_info(token_data.token, token_id)
# Get subscription info from Sora API
plan_type = None
plan_title = None
subscription_end = None
try:
sub_info = await self.get_subscription_info(token_data.token, token_id)
plan_type = sub_info.get("plan_type")
plan_title = sub_info.get("plan_title")
# Parse subscription end time
if sub_info.get("subscription_end"):
from dateutil import parser
subscription_end = parser.parse(sub_info["subscription_end"])
except Exception as e:
print(f"Failed to get subscription info: {e}")
# Refresh Sora2 invite code and counts
sora2_info = await self.get_sora2_invite_code(token_data.token, token_id)
sora2_supported = sora2_info.get("supported", False)
@@ -950,6 +1001,14 @@ class TokenManager:
except Exception as e:
print(f"Failed to get Sora2 remaining count: {e}")
# Update token subscription info in database
await self.db.update_token(
token_id,
plan_type=plan_type,
plan_title=plan_title,
subscription_end=subscription_end
)
# Update token Sora2 info in database
await self.db.update_token_sora2(
token_id,
@@ -960,11 +1019,17 @@ class TokenManager:
remaining_count=sora2_remaining_count
)
# Clear expired flag if token is valid
await self.db.clear_token_expired(token_id)
return {
"valid": True,
"message": "Token is valid",
"message": "Token is valid and account info updated",
"email": user_info.get("email"),
"username": user_info.get("username"),
"plan_type": plan_type,
"plan_title": plan_title,
"subscription_end": subscription_end.isoformat() if subscription_end else None,
"sora2_supported": sora2_supported,
"sora2_invite_code": sora2_invite_code,
"sora2_redeemed_count": sora2_redeemed_count,
@@ -972,9 +1037,18 @@ class TokenManager:
"sora2_remaining_count": sora2_remaining_count
}
except Exception as e:
error_msg = str(e)
# Check if error is 401 with token_invalidated
if "401" in error_msg and "token_invalidated" in error_msg.lower():
# Mark token as expired
await self.db.mark_token_expired(token_id)
return {
"valid": False,
"message": "Token已过期token_invalidated"
}
return {
"valid": False,
"message": f"Token is invalid: {str(e)}"
"message": f"Token is invalid: {error_msg}"
}
async def record_usage(self, token_id: int, is_video: bool = False):

View File

@@ -331,6 +331,17 @@
</label>
<p class="text-xs text-muted-foreground mt-2">开启后详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件,立即生效</p>
</div>
<div>
<button onclick="downloadDebugLogs()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 text-sm font-medium transition-colors">
<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"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下载日志文件
</button>
<p class="text-xs text-muted-foreground mt-2">下载完整的调试日志文件 (logs.txt)</p>
</div>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
<p class="text-xs text-yellow-800 dark:text-yellow-200">
⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志仅限Debug时候开启否则磁盘boom
@@ -666,6 +677,40 @@
<input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">选择导入模式</label>
<select id="importMode" onchange="updateImportModeHint()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="at">优先使用AT导入推荐</option>
<option value="offline">离线导入(不更新账号状态)</option>
<option value="st">优先使用ST导入</option>
<option value="rt">优先使用RT导入</option>
</select>
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态订阅信息、Sora2次数等</p>
</div>
<div class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">📋 导入模式说明</p>
<div class="space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">AT导入</span>
<span>完整更新所有账号信息(推荐)</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">离线导入:</span>
<span>直接插入数据库快速导入不调用API账号信息需要单独获取 -</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">ST导入</span>
<span>适用于只有ST没有AT自动转换为AT</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">RT导入</span>
<span>适用于只有RT没有AT自动转换为AT</span>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
💡 提示:离线导入后可使用"测试"按钮更新账号信息功能不稳定有bug问猫猫
</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-800 dark:text-blue-200">
<strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
@@ -719,7 +764,7 @@
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}`:'-';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 ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</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 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('')},
refreshTokens=async()=>{await loadTokens();await loadStats()},
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')},
@@ -743,14 +788,16 @@
openImportProgressModal=()=>{$('importProgressModal').classList.remove('hidden')},
closeImportProgressModal=()=>{$('importProgressModal').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,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')},
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}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}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})});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')}},
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')},
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')}},
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},