feat: 添加资源缓存开关、自动刷新AT fix: 修复邀请码复制、修复移动端无法滑动、优化图片、视频格式输出

This commit is contained in:
TheSmallHanCat
2025-11-09 18:26:37 +08:00
parent 7269e3fa79
commit b6cedb0ece
11 changed files with 382 additions and 113 deletions

View File

@@ -157,12 +157,12 @@ python main.py
| 模型 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `sora-image` | 文生图(默认横屏) | 文本 | 图片 |
| `sora-image-landscape` | 文生图(横屏) | 文本 | 图片 |
| `sora-image-portrait` | 文生图(竖屏) | 文本 | 图片 |
| `sora-video` | 文生视频(默认横屏) | 文本 | 视频 |
| `sora-video-landscape` | 文生视频(横屏) | 文本 | 视频 |
| `sora-video-portrait` | 文生视频(竖屏) | 文本 | 视频 |
| `sora-image` | 文生图(默认横屏) | 文本/图片 | 图片 |
| `sora-image-landscape` | 文生图(横屏) | 文本/图片 | 图片 |
| `sora-image-portrait` | 文生图(竖屏) | 文本/图片 | 图片 |
| `sora-video` | 文生视频(默认横屏) | 文本/图片 | 视频 |
| `sora-video-landscape` | 文生视频(横屏) | 文本/图片 | 视频 |
| `sora-video-portrait` | 文生视频(竖屏) | 文本/图片 | 视频 |
#### 请求示例

View File

@@ -21,6 +21,7 @@ log_responses = true
mask_token = true
[cache]
enabled = false
timeout = 600
base_url = "http://127.0.0.1:8000"
@@ -44,6 +45,9 @@ custom_parse_token = ""
[video_length]
default_length = "10s"
[token_refresh]
at_auto_refresh_enabled = false
[video_length.lengths]
10s = 300
15s = 450

View File

@@ -21,6 +21,7 @@ log_responses = true
mask_token = true
[cache]
enabled = true
timeout = 600
base_url = "http://127.0.0.1:8000"
@@ -37,6 +38,9 @@ proxy_url = "socks5://warp:1080"
[watermark_free]
watermark_free_enabled = false
parse_method = "third_party"
custom_parse_url = ""
custom_parse_token = ""
[video_length]
default_length = "10s"
@@ -44,3 +48,6 @@ default_length = "10s"
[video_length.lengths]
10s = 300
15s = 450
[token_refresh]
at_auto_refresh_enabled = false

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
sora2api:
image: thesmallhancat/sora2api:3.0
image: thesmallhancat/sora2api:3.1
container_name: sora2api
ports:
- "8000:8000"

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
sora2api:
image: thesmallhancat/sora2api:3.0
image: thesmallhancat/sora2api:3.1
container_name: sora2api
ports:
- "8000:8000"

View File

@@ -729,12 +729,49 @@ async def get_cache_config(token: str = Depends(verify_admin_token)):
return {
"success": True,
"config": {
"enabled": config.cache_enabled,
"timeout": config.cache_timeout,
"base_url": config.cache_base_url, # 返回实际配置的值,可能为空字符串
"effective_base_url": config.cache_base_url or f"http://{config.server_host}:{config.server_port}" # 实际生效的值
}
}
@router.post("/api/cache/enabled")
async def update_cache_enabled(
request: dict,
token: str = Depends(verify_admin_token)
):
"""Update cache enabled status"""
try:
enabled = request.get("enabled", True)
# Update config file
config_path = Path("config/setting.toml")
with open(config_path, "r", encoding="utf-8") as f:
config_data = toml.load(f)
if "cache" not in config_data:
config_data["cache"] = {}
config_data["cache"]["enabled"] = enabled
with open(config_path, "w", encoding="utf-8") as f:
toml.dump(config_data, f)
# Update in-memory config
config.set_cache_enabled(enabled)
# Reload config to ensure consistency
config.reload_config()
return {
"success": True,
"message": f"Cache {'enabled' if enabled else 'disabled'} successfully",
"enabled": enabled
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update cache enabled status: {str(e)}")
# Generation timeout config endpoints
@router.get("/api/generation/timeout")
async def get_generation_timeout(token: str = Depends(verify_admin_token)):
@@ -862,3 +899,53 @@ async def update_video_length_config(
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update video length config: {str(e)}")
# AT auto refresh config endpoints
@router.get("/api/token-refresh/config")
async def get_at_auto_refresh_config(token: str = Depends(verify_admin_token)):
"""Get AT auto refresh configuration"""
# Reload config from file to get latest values
config.reload_config()
return {
"success": True,
"config": {
"at_auto_refresh_enabled": config.at_auto_refresh_enabled
}
}
@router.post("/api/token-refresh/enabled")
async def update_at_auto_refresh_enabled(
request: dict,
token: str = Depends(verify_admin_token)
):
"""Update AT auto refresh enabled status"""
try:
enabled = request.get("enabled", False)
# Update config file
config_path = Path("config/setting.toml")
with open(config_path, "r", encoding="utf-8") as f:
config_data = toml.load(f)
if "token_refresh" not in config_data:
config_data["token_refresh"] = {}
config_data["token_refresh"]["at_auto_refresh_enabled"] = enabled
with open(config_path, "w", encoding="utf-8") as f:
toml.dump(config_data, f)
# Update in-memory config
config.set_at_auto_refresh_enabled(enabled)
# Reload config to ensure consistency
config.reload_config()
return {
"success": True,
"message": f"AT auto refresh {'enabled' if enabled else 'disabled'} successfully",
"enabled": enabled
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")

View File

@@ -120,6 +120,17 @@ class Config:
self._config["cache"] = {}
self._config["cache"]["base_url"] = base_url
@property
def cache_enabled(self) -> bool:
"""Get cache enabled status"""
return self._config.get("cache", {}).get("enabled", True)
def set_cache_enabled(self, enabled: bool):
"""Set cache enabled status"""
if "cache" not in self._config:
self._config["cache"] = {}
self._config["cache"]["enabled"] = enabled
@property
def image_timeout(self) -> int:
"""Get image generation timeout in seconds"""
@@ -168,5 +179,16 @@ class Config:
"""Get custom parse server access token"""
return self._config.get("watermark_free", {}).get("custom_parse_token", "")
@property
def at_auto_refresh_enabled(self) -> bool:
"""Get AT auto refresh enabled status"""
return self._config.get("token_refresh", {}).get("at_auto_refresh_enabled", False)
def set_at_auto_refresh_enabled(self, enabled: bool):
"""Set AT auto refresh enabled/disabled"""
if "token_refresh" not in self._config:
self._config["token_refresh"] = {}
self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
# Global config instance
config = Config()

View File

@@ -229,6 +229,8 @@ class GenerationHandler:
start_time = time.time()
last_heartbeat_time = start_time # Track last heartbeat for image generation
heartbeat_interval = 10 # Send heartbeat every 10 seconds for image generation
last_status_output_time = start_time # Track last status output time for video generation
video_status_interval = 30 # Output status every 30 seconds for video generation
debug_logger.log_info(f"Starting task polling: task_id={task_id}, is_video={is_video}, timeout={timeout}s, max_attempts={max_attempts}")
@@ -275,16 +277,18 @@ class GenerationHandler:
else:
progress_pct = int(progress_pct * 100)
# Only yield progress update if it changed
if progress_pct != last_progress:
last_progress = progress_pct
status = task.get("status", "processing")
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
# Update last_progress for tracking
last_progress = progress_pct
status = task.get("status", "processing")
if stream:
yield self._format_stream_chunk(
reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
)
# 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):
last_status_output_time = current_time
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
yield self._format_stream_chunk(
reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
)
break
# If task not found in pending tasks, it's completed - fetch from drafts
@@ -356,43 +360,51 @@ class GenerationHandler:
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Video published successfully. Post ID: {post_id}\nNow caching watermark-free video...\n"
reasoning_content=f"Video published successfully. Post ID: {post_id}\nNow {'caching' if config.cache_enabled else 'preparing'} watermark-free video...\n"
)
# Cache watermark-free video
try:
cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
yield self._format_stream_chunk(
reasoning_content="Watermark-free video cached successfully. Preparing final response...\n"
)
# Delete the published post after caching
# Cache watermark-free video (if cache enabled)
if config.cache_enabled:
try:
debug_logger.log_info(f"Deleting published post: {post_id}")
await self.sora_client.delete_post(post_id, token)
debug_logger.log_info(f"Published post deleted successfully: {post_id}")
cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
yield self._format_stream_chunk(
reasoning_content="Published post deleted successfully.\n"
reasoning_content="Watermark-free video cached successfully. Preparing final response...\n"
)
except Exception as delete_error:
debug_logger.log_error(
error_message=f"Failed to delete published post {post_id}: {str(delete_error)}",
status_code=500,
response_text=str(delete_error)
)
# Delete the published post after caching
try:
debug_logger.log_info(f"Deleting published post: {post_id}")
await self.sora_client.delete_post(post_id, token)
debug_logger.log_info(f"Published post deleted successfully: {post_id}")
if stream:
yield self._format_stream_chunk(
reasoning_content="Published post deleted successfully.\n"
)
except Exception as delete_error:
debug_logger.log_error(
error_message=f"Failed to delete published post {post_id}: {str(delete_error)}",
status_code=500,
response_text=str(delete_error)
)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to delete published post - {str(delete_error)}\n"
)
except Exception as cache_error:
# Fallback to watermark-free URL if caching fails
local_url = watermark_free_url
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to delete published post - {str(delete_error)}\n"
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original watermark-free URL instead...\n"
)
except Exception as cache_error:
# Fallback to watermark-free URL if caching fails
else:
# Cache disabled: use watermark-free URL directly
local_url = watermark_free_url
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original watermark-free URL instead...\n"
reasoning_content="Cache is disabled. Using watermark-free URL directly...\n"
)
except Exception as publish_error:
@@ -410,34 +422,45 @@ class GenerationHandler:
url = item.get("downloadable_url") or item.get("url")
if not url:
raise Exception("Video URL not found")
try:
cached_filename = await self.file_cache.download_and_cache(url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
except Exception as cache_error:
if config.cache_enabled:
try:
cached_filename = await self.file_cache.download_and_cache(url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
except Exception as cache_error:
local_url = url
else:
local_url = url
else:
# Normal mode: use downloadable_url instead of url
url = item.get("downloadable_url") or item.get("url")
if url:
# Cache video file
if stream:
yield self._format_stream_chunk(
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
)
try:
cached_filename = await self.file_cache.download_and_cache(url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
# Cache video file (if cache enabled)
if config.cache_enabled:
if stream:
yield self._format_stream_chunk(
reasoning_content="Video file cached successfully. Preparing final response...\n"
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
)
except Exception as cache_error:
# Fallback to original URL if caching fails
try:
cached_filename = await self.file_cache.download_and_cache(url, "video")
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
yield self._format_stream_chunk(
reasoning_content="Video file cached successfully. Preparing final response...\n"
)
except Exception as cache_error:
# Fallback to original URL if caching fails
local_url = url
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
)
else:
# Cache disabled: use original URL directly
local_url = url
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
)
# Task completed
@@ -482,27 +505,37 @@ class GenerationHandler:
base_url = self._get_base_url()
local_urls = []
for idx, url in enumerate(urls):
try:
cached_filename = await self.file_cache.download_and_cache(url, "image")
local_url = f"{base_url}/tmp/{cached_filename}"
local_urls.append(local_url)
if stream and len(urls) > 1:
yield self._format_stream_chunk(
reasoning_content=f"Cached image {idx + 1}/{len(urls)}...\n"
)
except Exception as cache_error:
# Fallback to original URL if caching fails
local_urls.append(url)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache image {idx + 1} - {str(cache_error)}\nUsing original URL instead...\n"
)
if stream and all(u.startswith(base_url) for u in local_urls):
yield self._format_stream_chunk(
reasoning_content="All images cached successfully. Preparing final response...\n"
)
# Check if cache is enabled
if config.cache_enabled:
for idx, url in enumerate(urls):
try:
cached_filename = await self.file_cache.download_and_cache(url, "image")
local_url = f"{base_url}/tmp/{cached_filename}"
local_urls.append(local_url)
if stream and len(urls) > 1:
yield self._format_stream_chunk(
reasoning_content=f"Cached image {idx + 1}/{len(urls)}...\n"
)
except Exception as cache_error:
# Fallback to original URL if caching fails
local_urls.append(url)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache image {idx + 1} - {str(cache_error)}\nUsing original URL instead...\n"
)
if stream and all(u.startswith(base_url) for u in local_urls):
yield self._format_stream_chunk(
reasoning_content="All images cached successfully. Preparing final response...\n"
)
else:
# Cache disabled: use original URLs directly
local_urls = urls
if stream:
yield self._format_stream_chunk(
reasoning_content="Cache is disabled. Using original URLs directly...\n"
)
await self.db.update_task(
task_id, "completed", 100.0,
@@ -510,10 +543,10 @@ class GenerationHandler:
)
if stream:
# Final response with content
content_html = "".join([f"<img src='{url}' />" for url in local_urls])
# Final response with content (Markdown format)
content_markdown = "\n".join([f"![Generated Image]({url})" for url in local_urls])
yield self._format_stream_chunk(
content=content_html,
content=content_markdown,
finish_reason="STOP"
)
yield "data: [DONE]\n\n"
@@ -544,7 +577,7 @@ class GenerationHandler:
last_heartbeat_time = current_time
elapsed = int(current_time - start_time)
yield self._format_stream_chunk(
reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n"
reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
)
# If task not found in response, send heartbeat for image generation
@@ -554,7 +587,7 @@ class GenerationHandler:
last_heartbeat_time = current_time
elapsed = int(current_time - start_time)
yield self._format_stream_chunk(
reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n"
reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
)
# Progress update for stream mode (fallback if no status from API)
@@ -638,7 +671,7 @@ class GenerationHandler:
if media_type == "video":
content = f"```html\n<video src='{url}' controls></video>\n```"
else:
content = f"<img src='{url}' />"
content = f"![Generated Image]({url})"
response = {
"id": f"chatcmpl-{datetime.now().timestamp()}",

View File

@@ -25,6 +25,18 @@ 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:
all_tokens = await self.token_manager.get_all_tokens()
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:
await self.token_manager.auto_refresh_expiring_token(token.id)
active_tokens = await self.token_manager.get_active_tokens()
if not active_tokens:

View File

@@ -871,3 +871,77 @@ class TokenManager:
print(f"Failed to refresh Sora2 remaining count: {e}")
except Exception as e:
print(f"Error in refresh_sora2_remaining_if_cooldown_expired: {e}")
async def auto_refresh_expiring_token(self, token_id: int) -> bool:
"""
Auto refresh token when expiry time is within 24 hours using ST or RT
Returns:
True if refresh successful, False otherwise
"""
try:
token_data = await self.db.get_token(token_id)
if not token_data:
return False
# Check if token is expiring within 24 hours
if not token_data.expiry_time:
return False # No expiry time set
time_until_expiry = token_data.expiry_time - datetime.now()
hours_until_expiry = time_until_expiry.total_seconds() / 3600
# Only refresh if expiry is within 24 hours (1440 minutes)
if hours_until_expiry > 24:
return False # Token not expiring soon
if hours_until_expiry < 0:
# Token already expired, still try to refresh
print(f"🔄 Token {token_id} 已过期,尝试自动刷新...")
else:
print(f"🔄 Token {token_id} 将在 {hours_until_expiry:.1f} 小时后过期,尝试自动刷新...")
# Priority: ST > RT
new_at = None
new_st = None
new_rt = None
if token_data.st:
# Try to refresh using ST
try:
print(f"📝 使用 ST 刷新 Token {token_id}...")
result = await self.st_to_at(token_data.st)
new_at = result.get("access_token")
# ST refresh doesn't return new ST, so keep the old one
new_st = token_data.st
print(f"✅ 使用 ST 刷新成功")
except Exception as e:
print(f"❌ 使用 ST 刷新失败: {e}")
new_at = None
if not new_at and token_data.rt:
# Try to refresh using RT
try:
print(f"📝 使用 RT 刷新 Token {token_id}...")
result = await self.rt_to_at(token_data.rt)
new_at = result.get("access_token")
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
print(f"✅ 使用 RT 刷新成功")
except Exception as e:
print(f"❌ 使用 RT 刷新失败: {e}")
new_at = None
if new_at:
# Update token with new AT
await self.update_token(token_id, token=new_at, st=new_st, rt=new_rt)
print(f"✅ Token {token_id} 已自动刷新")
return True
else:
# No ST or RT, disable token
print(f"⚠️ Token {token_id} 无法刷新(无 ST 或 RT已禁用")
await self.disable_token(token_id)
return False
except Exception as e:
print(f"❌ 自动刷新 Token {token_id} 失败: {e}")
return False

View File

@@ -74,7 +74,22 @@
<div class="rounded-lg border border-border bg-background">
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
<h3 class="text-lg font-semibold">Token 列表</h3>
<div class="flex items-center gap-2">
<div class="flex items-center gap-3">
<!-- 自动刷新AT标签和开关 -->
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">自动刷新AT</span>
<div class="relative inline-flex items-center group">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer">
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<!-- 悬浮提示 -->
<div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Token距离过期<24h时自动使用ST或RT刷新AT
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
</div>
<button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
@@ -194,20 +209,32 @@
<h3 class="text-lg font-semibold mb-4">缓存配置</h3>
<div class="space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400">
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间范围60-86400 秒1分钟-24小时</p>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
<span class="text-sm font-medium">启用缓存</span>
</label>
<p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址例如https://yourdomain.com</p>
</div>
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
<p class="text-xs text-muted-foreground">
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
</p>
<!-- 缓存配置选项 -->
<div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
<div>
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400">
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间范围60-86400 秒1分钟-24小时</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址例如https://yourdomain.com</p>
</div>
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
<p class="text-xs text-muted-foreground">
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
</p>
</div>
</div>
<button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
@@ -344,9 +371,9 @@
</main>
<!-- 添加 Token 模态框 -->
<div id="addModal" 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-2xl shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
<h3 class="text-lg font-semibold">添加 Token</h3>
<button onclick="closeAddModal()" 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">
@@ -355,7 +382,7 @@
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
<!-- Access Token -->
<div class="space-y-2">
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label>
@@ -411,7 +438,7 @@
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
<button onclick="closeAddModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
<button id="addTokenBtn" onclick="submitAddToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
<span id="addTokenBtnText">添加</span>
@@ -425,9 +452,9 @@
</div>
<!-- 编辑 Token 模态框 -->
<div id="editModal" 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-2xl shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
<h3 class="text-lg font-semibold">编辑 Token</h3>
<button onclick="closeEditModal()" 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">
@@ -436,7 +463,7 @@
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
<input type="hidden" id="editTokenId">
<!-- Access Token -->
@@ -494,7 +521,7 @@
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
<button onclick="closeEditModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
<button id="editTokenBtn" onclick="submitEditToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
<span id="editTokenBtnText">保存</span>
@@ -568,7 +595,7 @@
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{await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}catch(e){showToast('复制失败: '+e.message,'error')}},
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=''},
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')}},
@@ -583,18 +610,21 @@
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim();if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}},
loadVideoLengthConfig=async()=>{try{const r=await apiRequest('/api/video/length/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgVideoDefaultLength').value=d.config.default_length||'10s'}else{console.error('视频时长配置数据格式错误:',d)}}catch(e){console.error('加载视频时长配置失败:',e);showToast('加载视频时长配置失败: '+e.message,'error')}},
saveVideoLengthConfig=async()=>{try{const defaultLength=$('cfgVideoDefaultLength').value;const r=await apiRequest('/api/video/length/config',{method:'POST',body:JSON.stringify({default_length:defaultLength})});if(!r)return;const d=await r.json();if(d.success){showToast('视频时长配置保存成功','success');await loadVideoLengthConfig()}else{showToast('保存失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
saveCacheConfig=async()=>{const timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
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();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===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${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></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
refreshLogs=async()=>{await loadLogs()},
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'},
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadVideoLengthConfig()}else if(t==='logs'){loadLogs()}};
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens()});
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadVideoLengthConfig();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
</script>
</body>
</html>