mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-03-10 21:37:34 +08:00
Compare commits
20 Commits
eadce1a131
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29fddfa85b | ||
|
|
5a0ccbe2de | ||
|
|
fc95de0f28 | ||
|
|
92015882cc | ||
|
|
5570fa35a6 | ||
|
|
06c2bea806 | ||
|
|
576310c50c | ||
|
|
dab1f13310 | ||
|
|
50e004d722 | ||
|
|
ef49e3e670 | ||
|
|
d300f94683 | ||
|
|
ef1d1f90de | ||
|
|
5342435512 | ||
|
|
4b471ccb2b | ||
|
|
1703876ffa | ||
|
|
447079f863 | ||
|
|
a1ba92e8f6 | ||
|
|
a93d81bfc0 | ||
|
|
0cc1c2e32d | ||
|
|
881366175f |
39
Dockerfile
39
Dockerfile
@@ -1,10 +1,49 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set timezone to Asia/Shanghai (UTC+8) by default
|
||||||
|
# Can be overridden with -e TZ=<timezone> when running container
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
TIMEZONE_OFFSET=8
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for Playwright
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libasound2 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
libxshmfence1 \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libx11-6 \
|
||||||
|
libxcb1 \
|
||||||
|
libxext6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
RUN playwright install chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -582,7 +582,8 @@ for line in response.iter_lines():
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🙏 致谢
|
## 🙏 致谢
|
||||||
|
* 感谢 [@庚崽](https://github.com/genz27) 提供的POW验证解决方案
|
||||||
|
* 感谢 [@星火集市~小鑫学渣(93418328)](http://linggan10s.shop/) 提供的新的pow验证解决方案
|
||||||
感谢所有贡献者和使用者的支持!
|
感谢所有贡献者和使用者的支持!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ video_timeout = 3000
|
|||||||
|
|
||||||
[admin]
|
[admin]
|
||||||
error_ban_threshold = 3
|
error_ban_threshold = 3
|
||||||
|
# 任务失败重试配置
|
||||||
|
task_retry_enabled = true
|
||||||
|
task_max_retries = 3
|
||||||
|
# 401错误自动禁用token
|
||||||
|
auto_disable_on_401 = true
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
proxy_enabled = false
|
proxy_enabled = false
|
||||||
@@ -41,6 +46,29 @@ watermark_free_enabled = false
|
|||||||
parse_method = "third_party"
|
parse_method = "third_party"
|
||||||
custom_parse_url = ""
|
custom_parse_url = ""
|
||||||
custom_parse_token = ""
|
custom_parse_token = ""
|
||||||
|
fallback_on_failure = true # Auto fallback to watermarked video on failure
|
||||||
|
|
||||||
[token_refresh]
|
[token_refresh]
|
||||||
at_auto_refresh_enabled = false
|
at_auto_refresh_enabled = false
|
||||||
|
|
||||||
|
[call_logic]
|
||||||
|
call_mode = "default"
|
||||||
|
|
||||||
|
[timezone]
|
||||||
|
# 时区偏移小时数,默认为东八区(中国标准时间)
|
||||||
|
# 可选值:-12 到 +14 的整数
|
||||||
|
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
|
||||||
|
timezone_offset = 8
|
||||||
|
|
||||||
|
[pow_service]
|
||||||
|
# beta测试,目前仍处于测试阶段
|
||||||
|
# POW 计算模式:local(本地计算)或 external(外部服务)
|
||||||
|
mode = "external"
|
||||||
|
# 外部 POW 服务地址(仅在 external 模式下使用)
|
||||||
|
server_url = "http://localhost:8002"
|
||||||
|
# 外部 POW 服务访问密钥(仅在 external 模式下使用)
|
||||||
|
api_key = "your-secure-api-key-here"
|
||||||
|
# POW 代理配置
|
||||||
|
proxy_enabled = false
|
||||||
|
proxy_url = ""
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ services:
|
|||||||
- ./config/setting.toml:/app/config/setting.toml
|
- ./config/setting.toml:/app/config/setting.toml
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- TIMEZONE_OFFSET=8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ toml
|
|||||||
faker==24.0.0
|
faker==24.0.0
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
playwright==1.48.0
|
||||||
392
src/api/admin.py
392
src/api/admin.py
@@ -115,13 +115,26 @@ class ImportTokensRequest(BaseModel):
|
|||||||
tokens: List[ImportTokenItem]
|
tokens: List[ImportTokenItem]
|
||||||
mode: str = "at" # Import mode: offline/at/st/rt
|
mode: str = "at" # Import mode: offline/at/st/rt
|
||||||
|
|
||||||
|
class PureRtImportRequest(BaseModel):
|
||||||
|
refresh_tokens: List[str] # List of Refresh Tokens
|
||||||
|
client_id: str # Client ID (required)
|
||||||
|
proxy_url: Optional[str] = None # Proxy URL (optional)
|
||||||
|
image_concurrency: int = 1 # Image concurrency limit (default: 1)
|
||||||
|
video_concurrency: int = 3 # Video concurrency limit (default: 3)
|
||||||
|
|
||||||
class UpdateAdminConfigRequest(BaseModel):
|
class UpdateAdminConfigRequest(BaseModel):
|
||||||
error_ban_threshold: int
|
error_ban_threshold: int
|
||||||
|
task_retry_enabled: Optional[bool] = None
|
||||||
|
task_max_retries: Optional[int] = None
|
||||||
|
auto_disable_on_401: Optional[bool] = None
|
||||||
|
|
||||||
class UpdateProxyConfigRequest(BaseModel):
|
class UpdateProxyConfigRequest(BaseModel):
|
||||||
proxy_enabled: bool
|
proxy_enabled: bool
|
||||||
proxy_url: Optional[str] = None
|
proxy_url: Optional[str] = None
|
||||||
|
|
||||||
|
class TestProxyRequest(BaseModel):
|
||||||
|
test_url: Optional[str] = "https://sora.chatgpt.com"
|
||||||
|
|
||||||
class UpdateAdminPasswordRequest(BaseModel):
|
class UpdateAdminPasswordRequest(BaseModel):
|
||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
@@ -147,6 +160,23 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
|
|||||||
watermark_free_enabled: bool
|
watermark_free_enabled: bool
|
||||||
parse_method: Optional[str] = "third_party" # "third_party" or "custom"
|
parse_method: Optional[str] = "third_party" # "third_party" or "custom"
|
||||||
custom_parse_url: Optional[str] = None
|
custom_parse_url: Optional[str] = None
|
||||||
|
custom_parse_token: Optional[str] = None
|
||||||
|
fallback_on_failure: Optional[bool] = True # Auto fallback to watermarked video on failure
|
||||||
|
|
||||||
|
class UpdateCallLogicConfigRequest(BaseModel):
|
||||||
|
call_mode: Optional[str] = None # "default" or "polling"
|
||||||
|
polling_mode_enabled: Optional[bool] = None # Legacy support
|
||||||
|
|
||||||
|
class UpdatePowProxyConfigRequest(BaseModel):
|
||||||
|
pow_proxy_enabled: bool
|
||||||
|
pow_proxy_url: Optional[str] = None
|
||||||
|
|
||||||
|
class UpdatePowServiceConfigRequest(BaseModel):
|
||||||
|
mode: str # "local" or "external"
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
proxy_enabled: Optional[bool] = None
|
||||||
|
proxy_url: Optional[str] = None
|
||||||
|
|
||||||
class BatchDisableRequest(BaseModel):
|
class BatchDisableRequest(BaseModel):
|
||||||
token_ids: List[int]
|
token_ids: List[int]
|
||||||
@@ -219,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
|||||||
"video_enabled": token.video_enabled,
|
"video_enabled": token.video_enabled,
|
||||||
# 并发限制
|
# 并发限制
|
||||||
"image_concurrency": token.image_concurrency,
|
"image_concurrency": token.image_concurrency,
|
||||||
"video_concurrency": token.video_concurrency
|
"video_concurrency": token.video_concurrency,
|
||||||
|
# 过期和禁用信息
|
||||||
|
"is_expired": token.is_expired,
|
||||||
|
"disabled_reason": token.disabled_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -419,14 +452,16 @@ async def batch_enable_all(request: BatchDisableRequest = None, token: str = Dep
|
|||||||
|
|
||||||
@router.post("/api/tokens/batch/delete-disabled")
|
@router.post("/api/tokens/batch/delete-disabled")
|
||||||
async def batch_delete_disabled(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
|
async def batch_delete_disabled(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
|
||||||
"""Delete selected tokens or all disabled tokens"""
|
"""Delete selected disabled tokens or all disabled tokens"""
|
||||||
try:
|
try:
|
||||||
if request and request.token_ids:
|
if request and request.token_ids:
|
||||||
# Delete only selected tokens
|
# Delete only selected tokens that are disabled
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
for token_id in request.token_ids:
|
for token_id in request.token_ids:
|
||||||
await token_manager.delete_token(token_id)
|
token_obj = await db.get_token(token_id)
|
||||||
deleted_count += 1
|
if token_obj and not token_obj.is_active:
|
||||||
|
await token_manager.delete_token(token_id)
|
||||||
|
deleted_count += 1
|
||||||
else:
|
else:
|
||||||
# Delete all disabled tokens (backward compatibility)
|
# Delete all disabled tokens (backward compatibility)
|
||||||
tokens = await db.get_all_tokens()
|
tokens = await db.get_all_tokens()
|
||||||
@@ -438,7 +473,7 @@ async def batch_delete_disabled(request: BatchDisableRequest = None, token: str
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"已删除 {deleted_count} 个Token",
|
"message": f"已删除 {deleted_count} 个禁用Token",
|
||||||
"deleted_count": deleted_count
|
"deleted_count": deleted_count
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -461,6 +496,23 @@ async def batch_disable_selected(request: BatchDisableRequest, token: str = Depe
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/api/tokens/batch/delete-selected")
|
||||||
|
async def batch_delete_selected(request: BatchDisableRequest, token: str = Depends(verify_admin_token)):
|
||||||
|
"""Delete selected tokens (regardless of their status)"""
|
||||||
|
try:
|
||||||
|
deleted_count = 0
|
||||||
|
for token_id in request.token_ids:
|
||||||
|
await token_manager.delete_token(token_id)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"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/update-proxy")
|
@router.post("/api/tokens/batch/update-proxy")
|
||||||
async def batch_update_proxy(request: BatchUpdateProxyRequest, token: str = Depends(verify_admin_token)):
|
async def batch_update_proxy(request: BatchUpdateProxyRequest, token: str = Depends(verify_admin_token)):
|
||||||
"""Batch update proxy for selected tokens"""
|
"""Batch update proxy for selected tokens"""
|
||||||
@@ -631,6 +683,111 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
|
|||||||
"results": results
|
"results": results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.post("/api/tokens/import/pure-rt")
|
||||||
|
async def import_pure_rt(request: PureRtImportRequest, token: str = Depends(verify_admin_token)):
|
||||||
|
"""Import tokens using pure RT mode (batch RT conversion and import)"""
|
||||||
|
added_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for rt in request.refresh_tokens:
|
||||||
|
try:
|
||||||
|
# Step 1: Use RT + client_id + proxy to refresh and get AT
|
||||||
|
rt_result = await token_manager.rt_to_at(
|
||||||
|
rt,
|
||||||
|
client_id=request.client_id,
|
||||||
|
proxy_url=request.proxy_url
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = rt_result.get("access_token")
|
||||||
|
new_refresh_token = rt_result.get("refresh_token", rt) # Use new RT if returned, else use original
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("Failed to get access_token from RT conversion")
|
||||||
|
|
||||||
|
# Step 2: Parse AT to get user info (email)
|
||||||
|
# The rt_to_at already includes email in the response
|
||||||
|
email = rt_result.get("email")
|
||||||
|
|
||||||
|
# If email not in rt_result, parse it from access_token
|
||||||
|
if not email:
|
||||||
|
import jwt
|
||||||
|
try:
|
||||||
|
decoded = jwt.decode(access_token, options={"verify_signature": False})
|
||||||
|
email = decoded.get("https://api.openai.com/profile", {}).get("email")
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to parse email from access_token: {str(e)}")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
raise ValueError("Failed to extract email from access_token")
|
||||||
|
|
||||||
|
# Step 3: Check if token with this email already exists
|
||||||
|
existing_token = await db.get_token_by_email(email)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
# Update existing token
|
||||||
|
await token_manager.update_token(
|
||||||
|
token_id=existing_token.id,
|
||||||
|
token=access_token,
|
||||||
|
st=None, # No ST in pure RT mode
|
||||||
|
rt=new_refresh_token, # Use refreshed RT
|
||||||
|
client_id=request.client_id,
|
||||||
|
proxy_url=request.proxy_url,
|
||||||
|
remark=None, # Keep existing remark
|
||||||
|
image_enabled=True,
|
||||||
|
video_enabled=True,
|
||||||
|
image_concurrency=request.image_concurrency,
|
||||||
|
video_concurrency=request.video_concurrency,
|
||||||
|
skip_status_update=False # Update status with new AT
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
results.append({
|
||||||
|
"email": email,
|
||||||
|
"status": "updated",
|
||||||
|
"message": "Token updated successfully"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Add new token
|
||||||
|
new_token = await token_manager.add_token(
|
||||||
|
token_value=access_token,
|
||||||
|
st=None, # No ST in pure RT mode
|
||||||
|
rt=new_refresh_token, # Use refreshed RT
|
||||||
|
client_id=request.client_id,
|
||||||
|
proxy_url=request.proxy_url,
|
||||||
|
remark=None,
|
||||||
|
update_if_exists=False,
|
||||||
|
image_enabled=True,
|
||||||
|
video_enabled=True,
|
||||||
|
image_concurrency=request.image_concurrency,
|
||||||
|
video_concurrency=request.video_concurrency,
|
||||||
|
skip_status_update=False, # Update status with new AT
|
||||||
|
email=email # Pass email for new token
|
||||||
|
)
|
||||||
|
added_count += 1
|
||||||
|
results.append({
|
||||||
|
"email": email,
|
||||||
|
"status": "added",
|
||||||
|
"message": "Token added successfully"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
results.append({
|
||||||
|
"email": "unknown",
|
||||||
|
"status": "failed",
|
||||||
|
"message": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Pure RT import completed: {added_count} added, {updated_count} updated, {failed_count} failed",
|
||||||
|
"added": added_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|
||||||
@router.put("/api/tokens/{token_id}")
|
@router.put("/api/tokens/{token_id}")
|
||||||
async def update_token(
|
async def update_token(
|
||||||
token_id: int,
|
token_id: int,
|
||||||
@@ -670,6 +827,9 @@ async def get_admin_config(token: str = Depends(verify_admin_token)) -> dict:
|
|||||||
admin_config = await db.get_admin_config()
|
admin_config = await db.get_admin_config()
|
||||||
return {
|
return {
|
||||||
"error_ban_threshold": admin_config.error_ban_threshold,
|
"error_ban_threshold": admin_config.error_ban_threshold,
|
||||||
|
"task_retry_enabled": admin_config.task_retry_enabled,
|
||||||
|
"task_max_retries": admin_config.task_max_retries,
|
||||||
|
"auto_disable_on_401": admin_config.auto_disable_on_401,
|
||||||
"api_key": config.api_key,
|
"api_key": config.api_key,
|
||||||
"admin_username": config.admin_username,
|
"admin_username": config.admin_username,
|
||||||
"debug_enabled": config.debug_enabled
|
"debug_enabled": config.debug_enabled
|
||||||
@@ -685,9 +845,17 @@ async def update_admin_config(
|
|||||||
# Get current admin config to preserve username and password
|
# Get current admin config to preserve username and password
|
||||||
current_config = await db.get_admin_config()
|
current_config = await db.get_admin_config()
|
||||||
|
|
||||||
# Update only the error_ban_threshold, preserve username and password
|
# Update error_ban_threshold
|
||||||
current_config.error_ban_threshold = request.error_ban_threshold
|
current_config.error_ban_threshold = request.error_ban_threshold
|
||||||
|
|
||||||
|
# Update retry settings if provided
|
||||||
|
if request.task_retry_enabled is not None:
|
||||||
|
current_config.task_retry_enabled = request.task_retry_enabled
|
||||||
|
if request.task_max_retries is not None:
|
||||||
|
current_config.task_max_retries = request.task_max_retries
|
||||||
|
if request.auto_disable_on_401 is not None:
|
||||||
|
current_config.auto_disable_on_401 = request.auto_disable_on_401
|
||||||
|
|
||||||
await db.update_admin_config(current_config)
|
await db.update_admin_config(current_config)
|
||||||
return {"success": True, "message": "Configuration updated"}
|
return {"success": True, "message": "Configuration updated"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -789,6 +957,50 @@ async def update_proxy_config(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/api/proxy/test")
|
||||||
|
async def test_proxy_config(
|
||||||
|
request: TestProxyRequest,
|
||||||
|
token: str = Depends(verify_admin_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Test proxy connectivity with custom URL"""
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
|
config_obj = await proxy_manager.get_proxy_config()
|
||||||
|
if not config_obj.proxy_enabled or not config_obj.proxy_url:
|
||||||
|
return {"success": False, "message": "代理未启用或地址为空"}
|
||||||
|
|
||||||
|
# Use provided test URL or default
|
||||||
|
test_url = request.test_url or "https://sora.chatgpt.com"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSession() as session:
|
||||||
|
response = await session.get(
|
||||||
|
test_url,
|
||||||
|
timeout=15,
|
||||||
|
impersonate="chrome",
|
||||||
|
proxy=config_obj.proxy_url
|
||||||
|
)
|
||||||
|
status_code = response.status_code
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"代理可用 (HTTP {status_code})",
|
||||||
|
"status_code": status_code,
|
||||||
|
"test_url": test_url
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"代理响应异常: HTTP {status_code}",
|
||||||
|
"status_code": status_code,
|
||||||
|
"test_url": test_url
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"代理连接失败: {str(e)}",
|
||||||
|
"test_url": test_url
|
||||||
|
}
|
||||||
|
|
||||||
# Watermark-free config endpoints
|
# Watermark-free config endpoints
|
||||||
@router.get("/api/watermark-free/config")
|
@router.get("/api/watermark-free/config")
|
||||||
async def get_watermark_free_config(token: str = Depends(verify_admin_token)) -> dict:
|
async def get_watermark_free_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
@@ -798,7 +1010,8 @@ async def get_watermark_free_config(token: str = Depends(verify_admin_token)) ->
|
|||||||
"watermark_free_enabled": config_obj.watermark_free_enabled,
|
"watermark_free_enabled": config_obj.watermark_free_enabled,
|
||||||
"parse_method": config_obj.parse_method,
|
"parse_method": config_obj.parse_method,
|
||||||
"custom_parse_url": config_obj.custom_parse_url,
|
"custom_parse_url": config_obj.custom_parse_url,
|
||||||
"custom_parse_token": config_obj.custom_parse_token
|
"custom_parse_token": config_obj.custom_parse_token,
|
||||||
|
"fallback_on_failure": config_obj.fallback_on_failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/api/watermark-free/config")
|
@router.post("/api/watermark-free/config")
|
||||||
@@ -812,7 +1025,8 @@ async def update_watermark_free_config(
|
|||||||
request.watermark_free_enabled,
|
request.watermark_free_enabled,
|
||||||
request.parse_method,
|
request.parse_method,
|
||||||
request.custom_parse_url,
|
request.custom_parse_url,
|
||||||
request.custom_parse_token
|
request.custom_parse_token,
|
||||||
|
request.fallback_on_failure
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update in-memory config
|
# Update in-memory config
|
||||||
@@ -862,9 +1076,16 @@ async def get_stats(token: str = Depends(verify_admin_token)):
|
|||||||
@router.get("/api/logs")
|
@router.get("/api/logs")
|
||||||
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
|
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
|
||||||
"""Get recent logs with token email and task progress"""
|
"""Get recent logs with token email and task progress"""
|
||||||
|
from src.utils.timezone import convert_utc_to_local
|
||||||
|
|
||||||
logs = await db.get_recent_logs(limit)
|
logs = await db.get_recent_logs(limit)
|
||||||
result = []
|
result = []
|
||||||
for log in logs:
|
for log in logs:
|
||||||
|
# Convert UTC time to local timezone
|
||||||
|
created_at = log.get("created_at")
|
||||||
|
if created_at:
|
||||||
|
created_at = convert_utc_to_local(created_at)
|
||||||
|
|
||||||
log_data = {
|
log_data = {
|
||||||
"id": log.get("id"),
|
"id": log.get("id"),
|
||||||
"token_id": log.get("token_id"),
|
"token_id": log.get("token_id"),
|
||||||
@@ -873,14 +1094,14 @@ async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
|
|||||||
"operation": log.get("operation"),
|
"operation": log.get("operation"),
|
||||||
"status_code": log.get("status_code"),
|
"status_code": log.get("status_code"),
|
||||||
"duration": log.get("duration"),
|
"duration": log.get("duration"),
|
||||||
"created_at": log.get("created_at"),
|
"created_at": created_at,
|
||||||
"request_body": log.get("request_body"),
|
"request_body": log.get("request_body"),
|
||||||
"response_body": log.get("response_body"),
|
"response_body": log.get("response_body"),
|
||||||
"task_id": log.get("task_id")
|
"task_id": log.get("task_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
# If task_id exists and status is in-progress, get task progress
|
# If task_id exists, get task progress and status
|
||||||
if log.get("task_id") and log.get("status_code") == -1:
|
if log.get("task_id"):
|
||||||
task = await db.get_task(log.get("task_id"))
|
task = await db.get_task(log.get("task_id"))
|
||||||
if task:
|
if task:
|
||||||
log_data["progress"] = task.progress
|
log_data["progress"] = task.progress
|
||||||
@@ -1120,6 +1341,132 @@ async def update_at_auto_refresh_enabled(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
|
||||||
|
|
||||||
|
# Call logic config endpoints
|
||||||
|
@router.get("/api/call-logic/config")
|
||||||
|
async def get_call_logic_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
|
"""Get call logic configuration"""
|
||||||
|
config_obj = await db.get_call_logic_config()
|
||||||
|
call_mode = getattr(config_obj, "call_mode", None)
|
||||||
|
if call_mode not in ("default", "polling"):
|
||||||
|
call_mode = "polling" if config_obj.polling_mode_enabled else "default"
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": {
|
||||||
|
"call_mode": call_mode,
|
||||||
|
"polling_mode_enabled": call_mode == "polling"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/call-logic/config")
|
||||||
|
async def update_call_logic_config(
|
||||||
|
request: UpdateCallLogicConfigRequest,
|
||||||
|
token: str = Depends(verify_admin_token)
|
||||||
|
):
|
||||||
|
"""Update call logic configuration"""
|
||||||
|
try:
|
||||||
|
call_mode = request.call_mode if request.call_mode in ("default", "polling") else None
|
||||||
|
if call_mode is None and request.polling_mode_enabled is not None:
|
||||||
|
call_mode = "polling" if request.polling_mode_enabled else "default"
|
||||||
|
if call_mode is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid call_mode")
|
||||||
|
|
||||||
|
await db.update_call_logic_config(call_mode)
|
||||||
|
config.set_call_logic_mode(call_mode)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Call logic configuration updated",
|
||||||
|
"call_mode": call_mode,
|
||||||
|
"polling_mode_enabled": call_mode == "polling"
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
|
||||||
|
|
||||||
|
# POW proxy config endpoints (redirected to pow_service config for unified management)
|
||||||
|
@router.get("/api/pow-proxy/config")
|
||||||
|
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
|
"""Get POW proxy configuration (unified with pow_service config)"""
|
||||||
|
# Read from pow_service config for unified management
|
||||||
|
config_obj = await db.get_pow_service_config()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": {
|
||||||
|
"pow_proxy_enabled": config_obj.proxy_enabled,
|
||||||
|
"pow_proxy_url": config_obj.proxy_url or ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/pow-proxy/config")
|
||||||
|
async def update_pow_proxy_config(
|
||||||
|
request: UpdatePowProxyConfigRequest,
|
||||||
|
token: str = Depends(verify_admin_token)
|
||||||
|
):
|
||||||
|
"""Update POW proxy configuration (unified with pow_service config)"""
|
||||||
|
try:
|
||||||
|
# Update pow_service config instead for unified management
|
||||||
|
config_obj = await db.get_pow_service_config()
|
||||||
|
await db.update_pow_service_config(
|
||||||
|
mode=config_obj.mode,
|
||||||
|
server_url=config_obj.server_url,
|
||||||
|
api_key=config_obj.api_key,
|
||||||
|
proxy_enabled=request.pow_proxy_enabled,
|
||||||
|
proxy_url=request.pow_proxy_url
|
||||||
|
)
|
||||||
|
# Update in-memory config
|
||||||
|
config.set_pow_service_proxy_enabled(request.pow_proxy_enabled)
|
||||||
|
config.set_pow_service_proxy_url(request.pow_proxy_url or "")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "POW proxy configuration updated"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update POW proxy configuration: {str(e)}")
|
||||||
|
|
||||||
|
# POW service config endpoints
|
||||||
|
@router.get("/api/pow/config")
|
||||||
|
async def get_pow_service_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
|
"""Get POW service configuration"""
|
||||||
|
config_obj = await db.get_pow_service_config()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": {
|
||||||
|
"mode": config_obj.mode,
|
||||||
|
"server_url": config_obj.server_url or "",
|
||||||
|
"api_key": config_obj.api_key or "",
|
||||||
|
"proxy_enabled": config_obj.proxy_enabled,
|
||||||
|
"proxy_url": config_obj.proxy_url or ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/pow/config")
|
||||||
|
async def update_pow_service_config(
|
||||||
|
request: UpdatePowServiceConfigRequest,
|
||||||
|
token: str = Depends(verify_admin_token)
|
||||||
|
):
|
||||||
|
"""Update POW service configuration"""
|
||||||
|
try:
|
||||||
|
await db.update_pow_service_config(
|
||||||
|
mode=request.mode,
|
||||||
|
server_url=request.server_url,
|
||||||
|
api_key=request.api_key,
|
||||||
|
proxy_enabled=request.proxy_enabled,
|
||||||
|
proxy_url=request.proxy_url
|
||||||
|
)
|
||||||
|
# Update runtime config
|
||||||
|
config.set_pow_service_mode(request.mode)
|
||||||
|
config.set_pow_service_server_url(request.server_url or "")
|
||||||
|
config.set_pow_service_api_key(request.api_key or "")
|
||||||
|
config.set_pow_service_proxy_enabled(request.proxy_enabled or False)
|
||||||
|
config.set_pow_service_proxy_url(request.proxy_url or "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "POW service configuration updated"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update POW service configuration: {str(e)}")
|
||||||
|
|
||||||
# Task management endpoints
|
# Task management endpoints
|
||||||
@router.post("/api/tasks/{task_id}/cancel")
|
@router.post("/api/tasks/{task_id}/cancel")
|
||||||
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
||||||
@@ -1142,7 +1489,26 @@ async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
|||||||
for log in logs:
|
for log in logs:
|
||||||
if log.get("task_id") == task_id and log.get("status_code") == -1:
|
if log.get("task_id") == task_id and log.get("status_code") == -1:
|
||||||
import time
|
import time
|
||||||
duration = time.time() - (log.get("created_at").timestamp() if log.get("created_at") else time.time())
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
created_at = log.get("created_at")
|
||||||
|
if created_at:
|
||||||
|
# If created_at is a string, parse it
|
||||||
|
if isinstance(created_at, str):
|
||||||
|
try:
|
||||||
|
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||||
|
duration = time.time() - created_at.timestamp()
|
||||||
|
except:
|
||||||
|
duration = 0
|
||||||
|
# If it's already a datetime object
|
||||||
|
elif isinstance(created_at, datetime):
|
||||||
|
duration = time.time() - created_at.timestamp()
|
||||||
|
else:
|
||||||
|
duration = 0
|
||||||
|
else:
|
||||||
|
duration = 0
|
||||||
|
|
||||||
await db.update_request_log(
|
await db.update_request_log(
|
||||||
log.get("id"),
|
log.get("id"),
|
||||||
response_body='{"error": "用户手动取消任务"}',
|
response_body='{"error": "用户手动取消任务"}',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""API routes - OpenAI compatible endpoints"""
|
"""API routes - OpenAI compatible endpoints"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from ..core.auth import verify_api_key_header
|
from ..core.auth import verify_api_key_header
|
||||||
from ..core.models import ChatCompletionRequest
|
from ..core.models import ChatCompletionRequest
|
||||||
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
|
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -71,10 +73,22 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
|
|||||||
@router.post("/v1/chat/completions")
|
@router.post("/v1/chat/completions")
|
||||||
async def create_chat_completion(
|
async def create_chat_completion(
|
||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
api_key: str = Depends(verify_api_key_header)
|
api_key: str = Depends(verify_api_key_header),
|
||||||
|
http_request: Request = None
|
||||||
):
|
):
|
||||||
"""Create chat completion (unified endpoint for image and video generation)"""
|
"""Create chat completion (unified endpoint for image and video generation)"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Log client request
|
||||||
|
debug_logger.log_request(
|
||||||
|
method="POST",
|
||||||
|
url="/v1/chat/completions",
|
||||||
|
headers=dict(http_request.headers) if http_request else {},
|
||||||
|
body=request.dict(),
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
|
|
||||||
# Extract prompt from messages
|
# Extract prompt from messages
|
||||||
if not request.messages:
|
if not request.messages:
|
||||||
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
||||||
@@ -142,7 +156,7 @@ async def create_chat_completion(
|
|||||||
if not request.stream:
|
if not request.stream:
|
||||||
# Non-streaming mode: only check availability
|
# Non-streaming mode: only check availability
|
||||||
result = None
|
result = None
|
||||||
async for chunk in generation_handler.handle_generation(
|
async for chunk in generation_handler.handle_generation_with_retry(
|
||||||
model=request.model,
|
model=request.model,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
image=image_data,
|
image=image_data,
|
||||||
@@ -153,25 +167,43 @@ async def create_chat_completion(
|
|||||||
result = chunk
|
result = chunk
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return JSONResponse(content=json.loads(result))
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
response_data = json.loads(result)
|
||||||
|
debug_logger.log_response(
|
||||||
|
status_code=200,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=response_data,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
|
return JSONResponse(content=response_data)
|
||||||
else:
|
else:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
error_response = {
|
||||||
|
"error": {
|
||||||
|
"message": "Availability check failed",
|
||||||
|
"type": "server_error",
|
||||||
|
"param": None,
|
||||||
|
"code": None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug_logger.log_response(
|
||||||
|
status_code=500,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=error_response,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={
|
content=error_response
|
||||||
"error": {
|
|
||||||
"message": "Availability check failed",
|
|
||||||
"type": "server_error",
|
|
||||||
"param": None,
|
|
||||||
"code": None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle streaming
|
# Handle streaming
|
||||||
if request.stream:
|
if request.stream:
|
||||||
async def generate():
|
async def generate():
|
||||||
try:
|
try:
|
||||||
async for chunk in generation_handler.handle_generation(
|
async for chunk in generation_handler.handle_generation_with_retry(
|
||||||
model=request.model,
|
model=request.model,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
image=image_data,
|
image=image_data,
|
||||||
@@ -218,7 +250,7 @@ async def create_chat_completion(
|
|||||||
else:
|
else:
|
||||||
# Non-streaming response (availability check only)
|
# Non-streaming response (availability check only)
|
||||||
result = None
|
result = None
|
||||||
async for chunk in generation_handler.handle_generation(
|
async for chunk in generation_handler.handle_generation_with_retry(
|
||||||
model=request.model,
|
model=request.model,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
image=image_data,
|
image=image_data,
|
||||||
@@ -229,31 +261,64 @@ async def create_chat_completion(
|
|||||||
result = chunk
|
result = chunk
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return JSONResponse(content=json.loads(result))
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
response_data = json.loads(result)
|
||||||
|
debug_logger.log_response(
|
||||||
|
status_code=200,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=response_data,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
|
return JSONResponse(content=response_data)
|
||||||
else:
|
else:
|
||||||
# Return OpenAI-compatible error format
|
# Return OpenAI-compatible error format
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
error_response = {
|
||||||
|
"error": {
|
||||||
|
"message": "Availability check failed",
|
||||||
|
"type": "server_error",
|
||||||
|
"param": None,
|
||||||
|
"code": None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug_logger.log_response(
|
||||||
|
status_code=500,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=error_response,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={
|
content=error_response
|
||||||
"error": {
|
|
||||||
"message": "Availability check failed",
|
|
||||||
"type": "server_error",
|
|
||||||
"param": None,
|
|
||||||
"code": None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Return OpenAI-compatible error format
|
# Return OpenAI-compatible error format
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
error_response = {
|
||||||
|
"error": {
|
||||||
|
"message": str(e),
|
||||||
|
"type": "server_error",
|
||||||
|
"param": None,
|
||||||
|
"code": None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=str(e),
|
||||||
|
status_code=500,
|
||||||
|
response_text=str(e),
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
|
debug_logger.log_response(
|
||||||
|
status_code=500,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=error_response,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
source="Client"
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={
|
content=error_response
|
||||||
"error": {
|
|
||||||
"message": str(e),
|
|
||||||
"type": "server_error",
|
|
||||||
"param": None,
|
|
||||||
"code": None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,5 +208,126 @@ class Config:
|
|||||||
self._config["token_refresh"] = {}
|
self._config["token_refresh"] = {}
|
||||||
self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
|
self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def polling_mode_enabled(self) -> bool:
|
||||||
|
"""Get polling mode enabled status"""
|
||||||
|
return self.call_logic_mode == "polling"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def call_logic_mode(self) -> str:
|
||||||
|
"""Get call logic mode (default or polling)"""
|
||||||
|
call_logic = self._config.get("call_logic", {})
|
||||||
|
mode = call_logic.get("call_mode")
|
||||||
|
if mode in ("default", "polling"):
|
||||||
|
return mode
|
||||||
|
if call_logic.get("polling_mode_enabled", False):
|
||||||
|
return "polling"
|
||||||
|
return "default"
|
||||||
|
|
||||||
|
def set_polling_mode_enabled(self, enabled: bool):
|
||||||
|
"""Set polling mode enabled/disabled"""
|
||||||
|
self.set_call_logic_mode("polling" if enabled else "default")
|
||||||
|
|
||||||
|
def set_call_logic_mode(self, mode: str):
|
||||||
|
"""Set call logic mode (default or polling)"""
|
||||||
|
normalized = "polling" if mode == "polling" else "default"
|
||||||
|
if "call_logic" not in self._config:
|
||||||
|
self._config["call_logic"] = {}
|
||||||
|
self._config["call_logic"]["call_mode"] = normalized
|
||||||
|
self._config["call_logic"]["polling_mode_enabled"] = normalized == "polling"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_proxy_enabled(self) -> bool:
|
||||||
|
"""Get POW proxy enabled status
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_enabled instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
|
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
|
||||||
|
|
||||||
|
def set_pow_proxy_enabled(self, enabled: bool):
|
||||||
|
"""Set POW proxy enabled/disabled
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_enabled instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
|
if "pow_proxy" not in self._config:
|
||||||
|
self._config["pow_proxy"] = {}
|
||||||
|
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_proxy_url(self) -> str:
|
||||||
|
"""Get POW proxy URL
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_url instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
|
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
|
||||||
|
|
||||||
|
def set_pow_proxy_url(self, url: str):
|
||||||
|
"""Set POW proxy URL
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_url instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
|
if "pow_proxy" not in self._config:
|
||||||
|
self._config["pow_proxy"] = {}
|
||||||
|
self._config["pow_proxy"]["pow_proxy_url"] = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_mode(self) -> str:
|
||||||
|
"""Get POW service mode (local or external)"""
|
||||||
|
return self._config.get("pow_service", {}).get("mode", "local")
|
||||||
|
|
||||||
|
def set_pow_service_mode(self, mode: str):
|
||||||
|
"""Set POW service mode"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["mode"] = mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_server_url(self) -> str:
|
||||||
|
"""Get POW service server URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("server_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_server_url(self, url: str):
|
||||||
|
"""Set POW service server URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["server_url"] = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_api_key(self) -> str:
|
||||||
|
"""Get POW service API key"""
|
||||||
|
return self._config.get("pow_service", {}).get("api_key", "")
|
||||||
|
|
||||||
|
def set_pow_service_api_key(self, api_key: str):
|
||||||
|
"""Set POW service API key"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["api_key"] = api_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_enabled(self) -> bool:
|
||||||
|
"""Get POW service proxy enabled status"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_enabled", False)
|
||||||
|
|
||||||
|
def set_pow_service_proxy_enabled(self, enabled: bool):
|
||||||
|
"""Set POW service proxy enabled status"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_enabled"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_url(self) -> str:
|
||||||
|
"""Get POW service proxy URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_proxy_url(self, url: str):
|
||||||
|
"""Set POW service proxy URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_url"] = url
|
||||||
|
|
||||||
# Global config instance
|
# Global config instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class Database:
|
|||||||
admin_password = "admin"
|
admin_password = "admin"
|
||||||
api_key = "han1234"
|
api_key = "han1234"
|
||||||
error_ban_threshold = 3
|
error_ban_threshold = 3
|
||||||
|
task_retry_enabled = True
|
||||||
|
task_max_retries = 3
|
||||||
|
auto_disable_on_401 = True
|
||||||
|
|
||||||
if config_dict:
|
if config_dict:
|
||||||
global_config = config_dict.get("global", {})
|
global_config = config_dict.get("global", {})
|
||||||
@@ -64,11 +67,14 @@ class Database:
|
|||||||
|
|
||||||
admin_config = config_dict.get("admin", {})
|
admin_config = config_dict.get("admin", {})
|
||||||
error_ban_threshold = admin_config.get("error_ban_threshold", 3)
|
error_ban_threshold = admin_config.get("error_ban_threshold", 3)
|
||||||
|
task_retry_enabled = admin_config.get("task_retry_enabled", True)
|
||||||
|
task_max_retries = admin_config.get("task_max_retries", 3)
|
||||||
|
auto_disable_on_401 = admin_config.get("auto_disable_on_401", True)
|
||||||
|
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold)
|
INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401)
|
||||||
VALUES (1, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (admin_username, admin_password, api_key, error_ban_threshold))
|
""", (admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401))
|
||||||
|
|
||||||
# Ensure proxy_config has a row
|
# Ensure proxy_config has a row
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
|
cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
|
||||||
@@ -99,6 +105,7 @@ class Database:
|
|||||||
parse_method = "third_party"
|
parse_method = "third_party"
|
||||||
custom_parse_url = None
|
custom_parse_url = None
|
||||||
custom_parse_token = None
|
custom_parse_token = None
|
||||||
|
fallback_on_failure = True # Default to True
|
||||||
|
|
||||||
if config_dict:
|
if config_dict:
|
||||||
watermark_config = config_dict.get("watermark_free", {})
|
watermark_config = config_dict.get("watermark_free", {})
|
||||||
@@ -106,15 +113,16 @@ class Database:
|
|||||||
parse_method = watermark_config.get("parse_method", "third_party")
|
parse_method = watermark_config.get("parse_method", "third_party")
|
||||||
custom_parse_url = watermark_config.get("custom_parse_url", "")
|
custom_parse_url = watermark_config.get("custom_parse_url", "")
|
||||||
custom_parse_token = watermark_config.get("custom_parse_token", "")
|
custom_parse_token = watermark_config.get("custom_parse_token", "")
|
||||||
|
fallback_on_failure = watermark_config.get("fallback_on_failure", True)
|
||||||
|
|
||||||
# Convert empty strings to None
|
# Convert empty strings to None
|
||||||
custom_parse_url = custom_parse_url if custom_parse_url else None
|
custom_parse_url = custom_parse_url if custom_parse_url else None
|
||||||
custom_parse_token = custom_parse_token if custom_parse_token else None
|
custom_parse_token = custom_parse_token if custom_parse_token else None
|
||||||
|
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token)
|
INSERT INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token, fallback_on_failure)
|
||||||
VALUES (1, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token))
|
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token, fallback_on_failure))
|
||||||
|
|
||||||
# Ensure cache_config has a row
|
# Ensure cache_config has a row
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
|
cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
|
||||||
@@ -172,6 +180,78 @@ class Database:
|
|||||||
VALUES (1, ?)
|
VALUES (1, ?)
|
||||||
""", (at_auto_refresh_enabled,))
|
""", (at_auto_refresh_enabled,))
|
||||||
|
|
||||||
|
# Ensure call_logic_config has a row
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM call_logic_config")
|
||||||
|
count = await cursor.fetchone()
|
||||||
|
if count[0] == 0:
|
||||||
|
# Get call logic config from config_dict if provided, otherwise use defaults
|
||||||
|
call_mode = "default"
|
||||||
|
polling_mode_enabled = False
|
||||||
|
|
||||||
|
if config_dict:
|
||||||
|
call_logic_config = config_dict.get("call_logic", {})
|
||||||
|
call_mode = call_logic_config.get("call_mode", "default")
|
||||||
|
# Normalize call_mode
|
||||||
|
if call_mode not in ("default", "polling"):
|
||||||
|
# Check legacy polling_mode_enabled field
|
||||||
|
polling_mode_enabled = call_logic_config.get("polling_mode_enabled", False)
|
||||||
|
call_mode = "polling" if polling_mode_enabled else "default"
|
||||||
|
else:
|
||||||
|
polling_mode_enabled = call_mode == "polling"
|
||||||
|
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
""", (call_mode, polling_mode_enabled))
|
||||||
|
|
||||||
|
# Ensure pow_proxy_config has a row
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
|
||||||
|
count = await cursor.fetchone()
|
||||||
|
if count[0] == 0:
|
||||||
|
# Get POW proxy config from config_dict if provided, otherwise use defaults
|
||||||
|
pow_proxy_enabled = False
|
||||||
|
pow_proxy_url = None
|
||||||
|
|
||||||
|
if config_dict:
|
||||||
|
pow_proxy_config = config_dict.get("pow_proxy", {})
|
||||||
|
pow_proxy_enabled = pow_proxy_config.get("pow_proxy_enabled", False)
|
||||||
|
pow_proxy_url = pow_proxy_config.get("pow_proxy_url", "")
|
||||||
|
# Convert empty string to None
|
||||||
|
pow_proxy_url = pow_proxy_url if pow_proxy_url else None
|
||||||
|
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO pow_proxy_config (id, pow_proxy_enabled, pow_proxy_url)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
""", (pow_proxy_enabled, pow_proxy_url))
|
||||||
|
|
||||||
|
# Ensure pow_service_config has a row
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM pow_service_config")
|
||||||
|
count = await cursor.fetchone()
|
||||||
|
if count[0] == 0:
|
||||||
|
# Get POW service config from config_dict if provided, otherwise use defaults
|
||||||
|
mode = "local"
|
||||||
|
server_url = None
|
||||||
|
api_key = None
|
||||||
|
proxy_enabled = False
|
||||||
|
proxy_url = None
|
||||||
|
|
||||||
|
if config_dict:
|
||||||
|
pow_service_config = config_dict.get("pow_service", {})
|
||||||
|
mode = pow_service_config.get("mode", "local")
|
||||||
|
server_url = pow_service_config.get("server_url", "")
|
||||||
|
api_key = pow_service_config.get("api_key", "")
|
||||||
|
proxy_enabled = pow_service_config.get("proxy_enabled", False)
|
||||||
|
proxy_url = pow_service_config.get("proxy_url", "")
|
||||||
|
# Convert empty strings to None
|
||||||
|
server_url = server_url if server_url else None
|
||||||
|
api_key = api_key if api_key else None
|
||||||
|
proxy_url = proxy_url if proxy_url else None
|
||||||
|
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO pow_service_config (id, mode, server_url, api_key, proxy_enabled, proxy_url)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
|
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
|
||||||
|
|
||||||
async def check_and_migrate_db(self, config_dict: dict = None):
|
async def check_and_migrate_db(self, config_dict: dict = None):
|
||||||
"""Check database integrity and perform migrations if needed
|
"""Check database integrity and perform migrations if needed
|
||||||
@@ -245,6 +325,7 @@ class Database:
|
|||||||
("parse_method", "TEXT DEFAULT 'third_party'"),
|
("parse_method", "TEXT DEFAULT 'third_party'"),
|
||||||
("custom_parse_url", "TEXT"),
|
("custom_parse_url", "TEXT"),
|
||||||
("custom_parse_token", "TEXT"),
|
("custom_parse_token", "TEXT"),
|
||||||
|
("fallback_on_failure", "BOOLEAN DEFAULT 1"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for col_name, col_type in columns_to_add:
|
for col_name, col_type in columns_to_add:
|
||||||
@@ -312,7 +393,8 @@ class Database:
|
|||||||
video_enabled BOOLEAN DEFAULT 1,
|
video_enabled BOOLEAN DEFAULT 1,
|
||||||
image_concurrency INTEGER DEFAULT -1,
|
image_concurrency INTEGER DEFAULT -1,
|
||||||
video_concurrency INTEGER DEFAULT -1,
|
video_concurrency INTEGER DEFAULT -1,
|
||||||
is_expired BOOLEAN DEFAULT 0
|
is_expired BOOLEAN DEFAULT 0,
|
||||||
|
disabled_reason TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -377,6 +459,9 @@ class Database:
|
|||||||
admin_password TEXT DEFAULT 'admin',
|
admin_password TEXT DEFAULT 'admin',
|
||||||
api_key TEXT DEFAULT 'han1234',
|
api_key TEXT DEFAULT 'han1234',
|
||||||
error_ban_threshold INTEGER DEFAULT 3,
|
error_ban_threshold INTEGER DEFAULT 3,
|
||||||
|
task_retry_enabled BOOLEAN DEFAULT 1,
|
||||||
|
task_max_retries INTEGER DEFAULT 3,
|
||||||
|
auto_disable_on_401 BOOLEAN DEFAULT 1,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -400,6 +485,7 @@ class Database:
|
|||||||
parse_method TEXT DEFAULT 'third_party',
|
parse_method TEXT DEFAULT 'third_party',
|
||||||
custom_parse_url TEXT,
|
custom_parse_url TEXT,
|
||||||
custom_parse_token TEXT,
|
custom_parse_token TEXT,
|
||||||
|
fallback_on_failure BOOLEAN DEFAULT 1,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -438,6 +524,42 @@ class Database:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Call logic config table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS call_logic_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
call_mode TEXT DEFAULT 'default',
|
||||||
|
polling_mode_enabled BOOLEAN DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# POW proxy config table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pow_proxy_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
pow_proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
|
pow_proxy_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create pow_service_config table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pow_service_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
mode TEXT DEFAULT 'local',
|
||||||
|
server_url TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
|
proxy_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Create indexes
|
# Create indexes
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
||||||
@@ -453,6 +575,34 @@ class Database:
|
|||||||
if not await self._column_exists(db, "token_stats", "today_date"):
|
if not await self._column_exists(db, "token_stats", "today_date"):
|
||||||
await db.execute("ALTER TABLE token_stats ADD COLUMN today_date DATE")
|
await db.execute("ALTER TABLE token_stats ADD COLUMN today_date DATE")
|
||||||
|
|
||||||
|
# Migration: Add retry_count column to tasks table if it doesn't exist
|
||||||
|
if not await self._column_exists(db, "tasks", "retry_count"):
|
||||||
|
await db.execute("ALTER TABLE tasks ADD COLUMN retry_count INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
# Migration: Add task retry config columns to admin_config table if they don't exist
|
||||||
|
if not await self._column_exists(db, "admin_config", "task_retry_enabled"):
|
||||||
|
await db.execute("ALTER TABLE admin_config ADD COLUMN task_retry_enabled BOOLEAN DEFAULT 1")
|
||||||
|
if not await self._column_exists(db, "admin_config", "task_max_retries"):
|
||||||
|
await db.execute("ALTER TABLE admin_config ADD COLUMN task_max_retries INTEGER DEFAULT 3")
|
||||||
|
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
|
||||||
|
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
|
||||||
|
|
||||||
|
# Migration: Add disabled_reason column to tokens table if it doesn't exist
|
||||||
|
if not await self._column_exists(db, "tokens", "disabled_reason"):
|
||||||
|
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
|
||||||
|
# For existing disabled tokens without a reason, set to 'manual'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'manual'
|
||||||
|
WHERE is_active = 0 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
# For existing expired tokens, set to 'expired'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'expired'
|
||||||
|
WHERE is_expired = 1 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
||||||
@@ -565,27 +715,35 @@ class Database:
|
|||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
|
||||||
"""Update token status"""
|
"""Update token status and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_active = ? WHERE id = ?
|
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
|
||||||
""", (is_active, token_id))
|
""", (is_active, disabled_reason, token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def mark_token_expired(self, token_id: int):
|
async def mark_token_expired(self, token_id: int):
|
||||||
"""Mark token as expired and disable it"""
|
"""Mark token as expired and disable it with reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
""", (token_id,))
|
""", ("expired", token_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def mark_token_invalid(self, token_id: int):
|
||||||
|
"""Mark token as invalid (401 error) and disable it"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
|
""", ("token_invalid", token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def clear_token_expired(self, token_id: int):
|
async def clear_token_expired(self, token_id: int):
|
||||||
"""Clear token expired flag"""
|
"""Clear token expired flag and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
|
||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -977,9 +1135,12 @@ class Database:
|
|||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE admin_config
|
UPDATE admin_config
|
||||||
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
|
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?,
|
||||||
|
task_retry_enabled = ?, task_max_retries = ?, auto_disable_on_401 = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold))
|
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold,
|
||||||
|
config.task_retry_enabled, config.task_max_retries, config.auto_disable_on_401))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Proxy config operations
|
# Proxy config operations
|
||||||
@@ -1019,10 +1180,11 @@ class Database:
|
|||||||
return WatermarkFreeConfig(watermark_free_enabled=False, parse_method="third_party")
|
return WatermarkFreeConfig(watermark_free_enabled=False, parse_method="third_party")
|
||||||
|
|
||||||
async def update_watermark_free_config(self, enabled: bool, parse_method: str = None,
|
async def update_watermark_free_config(self, enabled: bool, parse_method: str = None,
|
||||||
custom_parse_url: str = None, custom_parse_token: str = None):
|
custom_parse_url: str = None, custom_parse_token: str = None,
|
||||||
|
fallback_on_failure: bool = None):
|
||||||
"""Update watermark-free configuration"""
|
"""Update watermark-free configuration"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
if parse_method is None and custom_parse_url is None and custom_parse_token is None:
|
if parse_method is None and custom_parse_url is None and custom_parse_token is None and fallback_on_failure is None:
|
||||||
# Only update enabled status
|
# Only update enabled status
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE watermark_free_config
|
UPDATE watermark_free_config
|
||||||
@@ -1034,9 +1196,10 @@ class Database:
|
|||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE watermark_free_config
|
UPDATE watermark_free_config
|
||||||
SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?,
|
SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?,
|
||||||
custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP
|
custom_parse_token = ?, fallback_on_failure = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
""", (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token))
|
""", (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token,
|
||||||
|
fallback_on_failure if fallback_on_failure is not None else True))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Cache config operations
|
# Cache config operations
|
||||||
@@ -1141,3 +1304,87 @@ class Database:
|
|||||||
""", (at_auto_refresh_enabled,))
|
""", (at_auto_refresh_enabled,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Call logic config operations
|
||||||
|
async def get_call_logic_config(self) -> "CallLogicConfig":
|
||||||
|
"""Get call logic configuration"""
|
||||||
|
from .models import CallLogicConfig
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM call_logic_config WHERE id = 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
row_dict = dict(row)
|
||||||
|
if not row_dict.get("call_mode"):
|
||||||
|
row_dict["call_mode"] = "polling" if row_dict.get("polling_mode_enabled") else "default"
|
||||||
|
return CallLogicConfig(**row_dict)
|
||||||
|
return CallLogicConfig(call_mode="default", polling_mode_enabled=False)
|
||||||
|
|
||||||
|
async def update_call_logic_config(self, call_mode: str):
|
||||||
|
"""Update call logic configuration"""
|
||||||
|
normalized = "polling" if call_mode == "polling" else "default"
|
||||||
|
polling_mode_enabled = normalized == "polling"
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
|
||||||
|
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (normalized, polling_mode_enabled))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# POW proxy config operations
|
||||||
|
async def get_pow_proxy_config(self) -> "PowProxyConfig":
|
||||||
|
"""Get POW proxy configuration"""
|
||||||
|
from .models import PowProxyConfig
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM pow_proxy_config WHERE id = 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return PowProxyConfig(**dict(row))
|
||||||
|
return PowProxyConfig(pow_proxy_enabled=False, pow_proxy_url=None)
|
||||||
|
|
||||||
|
async def get_pow_service_config(self) -> "PowServiceConfig":
|
||||||
|
"""Get POW service configuration"""
|
||||||
|
from .models import PowServiceConfig
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM pow_service_config WHERE id = 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return PowServiceConfig(**dict(row))
|
||||||
|
return PowServiceConfig(
|
||||||
|
mode="local",
|
||||||
|
server_url=None,
|
||||||
|
api_key=None,
|
||||||
|
proxy_enabled=False,
|
||||||
|
proxy_url=None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_pow_proxy_config(self, pow_proxy_enabled: bool, pow_proxy_url: Optional[str] = None):
|
||||||
|
"""Update POW proxy configuration"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO pow_proxy_config (id, pow_proxy_enabled, pow_proxy_url, updated_at)
|
||||||
|
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (pow_proxy_enabled, pow_proxy_url))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def update_pow_service_config(
|
||||||
|
self,
|
||||||
|
mode: str,
|
||||||
|
server_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
proxy_enabled: Optional[bool] = None,
|
||||||
|
proxy_url: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Update POW service configuration"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO pow_service_config (id, mode, server_url, api_key, proxy_enabled, proxy_url, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,20 @@ class DebugLogger:
|
|||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
body: Optional[Any] = None,
|
body: Optional[Any] = None,
|
||||||
files: Optional[Dict] = None,
|
files: Optional[Dict] = None,
|
||||||
proxy: Optional[str] = None
|
proxy: Optional[str] = None,
|
||||||
|
source: str = "Server"
|
||||||
):
|
):
|
||||||
"""Log API request details to log.txt"""
|
"""Log API request details to log.txt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method
|
||||||
|
url: Request URL
|
||||||
|
headers: Request headers
|
||||||
|
body: Request body
|
||||||
|
files: Files to upload
|
||||||
|
proxy: Proxy URL
|
||||||
|
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
|
||||||
|
"""
|
||||||
|
|
||||||
# Check if debug mode is enabled
|
# Check if debug mode is enabled
|
||||||
if not config.debug_enabled:
|
if not config.debug_enabled:
|
||||||
@@ -78,7 +89,7 @@ class DebugLogger:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._write_separator()
|
self._write_separator()
|
||||||
self.logger.info(f"🔵 [REQUEST] {self._format_timestamp()}")
|
self.logger.info(f"🔵 [REQUEST][{source}] {self._format_timestamp()}")
|
||||||
self._write_separator("-")
|
self._write_separator("-")
|
||||||
|
|
||||||
# Basic info
|
# Basic info
|
||||||
@@ -136,9 +147,18 @@ class DebugLogger:
|
|||||||
status_code: int,
|
status_code: int,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
body: Any,
|
body: Any,
|
||||||
duration_ms: Optional[float] = None
|
duration_ms: Optional[float] = None,
|
||||||
|
source: str = "Server"
|
||||||
):
|
):
|
||||||
"""Log API response details to log.txt"""
|
"""Log API response details to log.txt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_code: HTTP status code
|
||||||
|
headers: Response headers
|
||||||
|
body: Response body
|
||||||
|
duration_ms: Request duration in milliseconds
|
||||||
|
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
|
||||||
|
"""
|
||||||
|
|
||||||
# Check if debug mode is enabled
|
# Check if debug mode is enabled
|
||||||
if not config.debug_enabled:
|
if not config.debug_enabled:
|
||||||
@@ -146,7 +166,7 @@ class DebugLogger:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._write_separator()
|
self._write_separator()
|
||||||
self.logger.info(f"🟢 [RESPONSE] {self._format_timestamp()}")
|
self.logger.info(f"🟢 [RESPONSE][{source}] {self._format_timestamp()}")
|
||||||
self._write_separator("-")
|
self._write_separator("-")
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
@@ -192,9 +212,17 @@ class DebugLogger:
|
|||||||
self,
|
self,
|
||||||
error_message: str,
|
error_message: str,
|
||||||
status_code: Optional[int] = None,
|
status_code: Optional[int] = None,
|
||||||
response_text: Optional[str] = None
|
response_text: Optional[str] = None,
|
||||||
|
source: str = "Server"
|
||||||
):
|
):
|
||||||
"""Log API error details to log.txt"""
|
"""Log API error details to log.txt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Error message
|
||||||
|
status_code: HTTP status code
|
||||||
|
response_text: Response text
|
||||||
|
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
|
||||||
|
"""
|
||||||
|
|
||||||
# Check if debug mode is enabled
|
# Check if debug mode is enabled
|
||||||
if not config.debug_enabled:
|
if not config.debug_enabled:
|
||||||
@@ -202,7 +230,7 @@ class DebugLogger:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._write_separator()
|
self._write_separator()
|
||||||
self.logger.info(f"🔴 [ERROR] {self._format_timestamp()}")
|
self.logger.info(f"🔴 [ERROR][{source}] {self._format_timestamp()}")
|
||||||
self._write_separator("-")
|
self._write_separator("-")
|
||||||
|
|
||||||
if status_code:
|
if status_code:
|
||||||
@@ -242,6 +270,18 @@ class DebugLogger:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error logging info: {e}")
|
self.logger.error(f"Error logging info: {e}")
|
||||||
|
|
||||||
|
def log_warning(self, message: str):
|
||||||
|
"""Log warning message to log.txt"""
|
||||||
|
|
||||||
|
# Check if debug mode is enabled
|
||||||
|
if not config.debug_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error logging warning: {e}")
|
||||||
|
|
||||||
# Global debug logger instance
|
# Global debug logger instance
|
||||||
debug_logger = DebugLogger()
|
debug_logger = DebugLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class Token(BaseModel):
|
|||||||
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
||||||
# 过期标记
|
# 过期标记
|
||||||
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
||||||
|
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
|
||||||
|
disabled_reason: Optional[str] = None
|
||||||
|
|
||||||
class TokenStats(BaseModel):
|
class TokenStats(BaseModel):
|
||||||
"""Token statistics"""
|
"""Token statistics"""
|
||||||
@@ -66,6 +68,7 @@ class Task(BaseModel):
|
|||||||
progress: float = 0.0
|
progress: float = 0.0
|
||||||
result_urls: Optional[str] = None # JSON array
|
result_urls: Optional[str] = None # JSON array
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
|
retry_count: int = 0 # 当前重试次数
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -89,6 +92,9 @@ class AdminConfig(BaseModel):
|
|||||||
admin_password: str # Read from database, initialized from setting.toml on first startup
|
admin_password: str # Read from database, initialized from setting.toml on first startup
|
||||||
api_key: str # Read from database, initialized from setting.toml on first startup
|
api_key: str # Read from database, initialized from setting.toml on first startup
|
||||||
error_ban_threshold: int = 3
|
error_ban_threshold: int = 3
|
||||||
|
task_retry_enabled: bool = True # 是否启用任务失败重试
|
||||||
|
task_max_retries: int = 3 # 任务最大重试次数
|
||||||
|
auto_disable_on_401: bool = True # 遇到401错误自动禁用token
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class ProxyConfig(BaseModel):
|
class ProxyConfig(BaseModel):
|
||||||
@@ -106,6 +112,7 @@ class WatermarkFreeConfig(BaseModel):
|
|||||||
parse_method: str # Read from database, initialized from setting.toml on first startup
|
parse_method: str # Read from database, initialized from setting.toml on first startup
|
||||||
custom_parse_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
custom_parse_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
||||||
custom_parse_token: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
custom_parse_token: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
||||||
|
fallback_on_failure: bool = True # Auto fallback to watermarked video on failure, default True
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -133,6 +140,33 @@ class TokenRefreshConfig(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class CallLogicConfig(BaseModel):
|
||||||
|
"""Call logic configuration"""
|
||||||
|
id: int = 1
|
||||||
|
call_mode: str = "default" # "default" or "polling"
|
||||||
|
polling_mode_enabled: bool = False # Read from database, initialized from setting.toml on first startup
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class PowProxyConfig(BaseModel):
|
||||||
|
"""POW proxy configuration"""
|
||||||
|
id: int = 1
|
||||||
|
pow_proxy_enabled: bool = False # Whether to enable POW proxy
|
||||||
|
pow_proxy_url: Optional[str] = None # POW proxy URL (e.g., http://127.0.0.1:7890)
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class PowServiceConfig(BaseModel):
|
||||||
|
"""POW service configuration"""
|
||||||
|
id: int = 1
|
||||||
|
mode: str = "local" # "local" or "external"
|
||||||
|
server_url: Optional[str] = None # External POW service URL
|
||||||
|
api_key: Optional[str] = None # External POW service API key
|
||||||
|
proxy_enabled: bool = False # Whether to enable proxy for POW service
|
||||||
|
proxy_url: Optional[str] = None # Proxy URL for POW service
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
# API Request/Response models
|
# API Request/Response models
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
|
|||||||
14
src/main.py
14
src/main.py
@@ -139,6 +139,20 @@ async def startup_event():
|
|||||||
token_refresh_config = await db.get_token_refresh_config()
|
token_refresh_config = await db.get_token_refresh_config()
|
||||||
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
|
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
|
||||||
|
|
||||||
|
# Load call logic configuration from database
|
||||||
|
call_logic_config = await db.get_call_logic_config()
|
||||||
|
config.set_call_logic_mode(call_logic_config.call_mode)
|
||||||
|
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
|
||||||
|
|
||||||
|
# Load POW service configuration from database
|
||||||
|
pow_service_config = await db.get_pow_service_config()
|
||||||
|
config.set_pow_service_mode(pow_service_config.mode)
|
||||||
|
config.set_pow_service_server_url(pow_service_config.server_url or "")
|
||||||
|
config.set_pow_service_api_key(pow_service_config.api_key or "")
|
||||||
|
config.set_pow_service_proxy_enabled(pow_service_config.proxy_enabled)
|
||||||
|
config.set_pow_service_proxy_url(pow_service_config.proxy_url or "")
|
||||||
|
print(f"✓ POW service mode: {pow_service_config.mode}")
|
||||||
|
|
||||||
# Initialize concurrency manager with all tokens
|
# Initialize concurrency manager with all tokens
|
||||||
all_tokens = await db.get_all_tokens()
|
all_tokens = await db.get_all_tokens()
|
||||||
await concurrency_manager.initialize(all_tokens)
|
await concurrency_manager.initialize(all_tokens)
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ from ..core.models import Task, RequestLog
|
|||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from ..core.logger import debug_logger
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
# Custom exception to carry token_id information
|
||||||
|
class GenerationError(Exception):
|
||||||
|
"""Custom exception for generation errors that includes token_id"""
|
||||||
|
def __init__(self, message: str, token_id: Optional[int] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.token_id = token_id
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
MODEL_CONFIG = {
|
MODEL_CONFIG = {
|
||||||
"gpt-image": {
|
"gpt-image": {
|
||||||
@@ -242,6 +249,26 @@ class GenerationHandler:
|
|||||||
video_str = video_str.split(",", 1)[1]
|
video_str = video_str.split(",", 1)[1]
|
||||||
return base64.b64decode(video_str)
|
return base64.b64decode(video_str)
|
||||||
|
|
||||||
|
def _should_retry_on_error(self, error: Exception) -> bool:
|
||||||
|
"""判断错误是否应该触发重试
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: 捕获的异常
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if should retry, False otherwise
|
||||||
|
"""
|
||||||
|
error_str = str(error).lower()
|
||||||
|
|
||||||
|
# 排除 CF Shield/429 错误(这些错误重试也会失败)
|
||||||
|
if "cf_shield" in error_str or "cloudflare" in error_str:
|
||||||
|
return False
|
||||||
|
if "429" in error_str or "rate limit" in error_str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 其他所有错误都可以重试
|
||||||
|
return True
|
||||||
|
|
||||||
def _process_character_username(self, username_hint: str) -> str:
|
def _process_character_username(self, username_hint: str) -> str:
|
||||||
"""Process character username from API response
|
"""Process character username from API response
|
||||||
|
|
||||||
@@ -380,7 +407,8 @@ class GenerationHandler:
|
|||||||
image: Optional[str] = None,
|
image: Optional[str] = None,
|
||||||
video: Optional[str] = None,
|
video: Optional[str] = None,
|
||||||
remix_target_id: Optional[str] = None,
|
remix_target_id: Optional[str] = None,
|
||||||
stream: bool = True) -> AsyncGenerator[str, None]:
|
stream: bool = True,
|
||||||
|
show_init_message: bool = True) -> AsyncGenerator[str, None]:
|
||||||
"""Handle generation request
|
"""Handle generation request
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -390,6 +418,7 @@ class GenerationHandler:
|
|||||||
video: Base64 encoded video or video URL
|
video: Base64 encoded video or video URL
|
||||||
remix_target_id: Sora share link video ID for remix
|
remix_target_id: Sora share link video ID for remix
|
||||||
stream: Whether to stream response
|
stream: Whether to stream response
|
||||||
|
show_init_message: Whether to show "Generation Process Begins" message
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
log_id = None # Initialize log_id to avoid reference before assignment
|
log_id = None # Initialize log_id to avoid reference before assignment
|
||||||
@@ -492,6 +521,7 @@ class GenerationHandler:
|
|||||||
task_id = None
|
task_id = None
|
||||||
is_first_chunk = True # Track if this is the first chunk
|
is_first_chunk = True # Track if this is the first chunk
|
||||||
log_id = None # Initialize log_id
|
log_id = None # Initialize log_id
|
||||||
|
log_updated = False # Track if log has been updated
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create initial log entry BEFORE submitting task to upstream
|
# Create initial log entry BEFORE submitting task to upstream
|
||||||
@@ -525,7 +555,7 @@ class GenerationHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Generate
|
# Generate
|
||||||
if stream:
|
if stream and show_init_message:
|
||||||
if is_first_chunk:
|
if is_first_chunk:
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="**Generation Process Begins**\n\nInitializing generation request...\n",
|
reasoning_content="**Generation Process Begins**\n\nInitializing generation request...\n",
|
||||||
@@ -651,6 +681,7 @@ class GenerationHandler:
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
log_updated = True # Mark log as updated
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release lock for image generation on error
|
# Release lock for image generation on error
|
||||||
@@ -698,6 +729,7 @@ class GenerationHandler:
|
|||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
log_updated = True # Mark log as updated
|
||||||
else:
|
else:
|
||||||
# Generic error
|
# Generic error
|
||||||
await self.db.update_request_log(
|
await self.db.update_request_log(
|
||||||
@@ -706,8 +738,124 @@ class GenerationHandler:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
raise e
|
log_updated = True # Mark log as updated
|
||||||
|
# Wrap exception with token_id information
|
||||||
|
if token_obj:
|
||||||
|
raise GenerationError(str(e), token_id=token_obj.id)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Ensure log is updated even if exception handling fails
|
||||||
|
# This prevents logs from being stuck at status_code = -1
|
||||||
|
if log_id and not log_updated:
|
||||||
|
try:
|
||||||
|
# Log was not updated in try or except blocks, update it now
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await self.db.update_request_log(
|
||||||
|
log_id,
|
||||||
|
response_body=json.dumps({"error": "Task failed or interrupted during processing"}),
|
||||||
|
status_code=500,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Updated stuck log entry {log_id} from status -1 to 500 in finally block")
|
||||||
|
except Exception as finally_error:
|
||||||
|
# Don't let finally block errors break the flow
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Failed to update log in finally block: {str(finally_error)}",
|
||||||
|
status_code=500,
|
||||||
|
response_text=str(finally_error)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_generation_with_retry(self, model: str, prompt: str,
|
||||||
|
image: Optional[str] = None,
|
||||||
|
video: Optional[str] = None,
|
||||||
|
remix_target_id: Optional[str] = None,
|
||||||
|
stream: bool = True) -> AsyncGenerator[str, None]:
|
||||||
|
"""Handle generation request with automatic retry on failure
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Model name
|
||||||
|
prompt: Generation prompt
|
||||||
|
image: Base64 encoded image
|
||||||
|
video: Base64 encoded video or video URL
|
||||||
|
remix_target_id: Sora share link video ID for remix
|
||||||
|
stream: Whether to stream response
|
||||||
|
"""
|
||||||
|
# Get admin config for retry settings
|
||||||
|
admin_config = await self.db.get_admin_config()
|
||||||
|
retry_enabled = admin_config.task_retry_enabled
|
||||||
|
max_retries = admin_config.task_max_retries if retry_enabled else 0
|
||||||
|
auto_disable_on_401 = admin_config.auto_disable_on_401
|
||||||
|
|
||||||
|
retry_count = 0
|
||||||
|
last_error = None
|
||||||
|
last_token_id = None # Track the token that caused the error
|
||||||
|
|
||||||
|
while retry_count <= max_retries:
|
||||||
|
try:
|
||||||
|
# Try generation
|
||||||
|
# Only show init message on first attempt (not on retries)
|
||||||
|
show_init = (retry_count == 0)
|
||||||
|
async for chunk in self.handle_generation(model, prompt, image, video, remix_target_id, stream, show_init_message=show_init):
|
||||||
|
yield chunk
|
||||||
|
# If successful, return
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
error_str = str(e)
|
||||||
|
|
||||||
|
# Extract token_id from GenerationError if available
|
||||||
|
if isinstance(e, GenerationError) and e.token_id:
|
||||||
|
last_token_id = e.token_id
|
||||||
|
|
||||||
|
# Check if this is a 401 error
|
||||||
|
is_401_error = "401" in error_str or "unauthorized" in error_str.lower() or "token_invalidated" in error_str.lower()
|
||||||
|
|
||||||
|
# If 401 error and auto-disable is enabled, disable the token
|
||||||
|
if is_401_error and auto_disable_on_401 and last_token_id:
|
||||||
|
debug_logger.log_info(f"Detected 401 error, auto-disabling token {last_token_id}")
|
||||||
|
try:
|
||||||
|
await self.db.update_token_status(last_token_id, False)
|
||||||
|
if stream:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"**检测到401错误,已自动禁用Token {last_token_id}**\\n\\n正在使用其他Token重试...\\n\\n"
|
||||||
|
)
|
||||||
|
except Exception as disable_error:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Failed to disable token {last_token_id}: {str(disable_error)}",
|
||||||
|
status_code=500,
|
||||||
|
response_text=str(disable_error)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we should retry
|
||||||
|
should_retry = (
|
||||||
|
retry_enabled and
|
||||||
|
retry_count < max_retries and
|
||||||
|
self._should_retry_on_error(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_retry:
|
||||||
|
retry_count += 1
|
||||||
|
debug_logger.log_info(f"Generation failed, retrying ({retry_count}/{max_retries}): {str(e)}")
|
||||||
|
|
||||||
|
# Send retry notification to user if streaming
|
||||||
|
if stream:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"**生成失败,正在重试**\n\n第 {retry_count} 次重试(共 {max_retries} 次)...\n\n失败原因:{str(e)}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Small delay before retry
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
# No more retries, raise the error
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
# If we exhausted all retries, raise the last error
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
|
||||||
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
|
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
|
||||||
stream: bool, prompt: str, token_id: int = None,
|
stream: bool, prompt: str, token_id: int = None,
|
||||||
log_id: int = None, start_time: float = None) -> AsyncGenerator[str, None]:
|
log_id: int = None, start_time: float = None) -> AsyncGenerator[str, None]:
|
||||||
@@ -864,11 +1012,15 @@ class GenerationHandler:
|
|||||||
watermark_free_config = await self.db.get_watermark_free_config()
|
watermark_free_config = await self.db.get_watermark_free_config()
|
||||||
watermark_free_enabled = watermark_free_config.watermark_free_enabled
|
watermark_free_enabled = watermark_free_config.watermark_free_enabled
|
||||||
|
|
||||||
|
# Initialize variables
|
||||||
|
local_url = None
|
||||||
|
watermark_free_failed = False
|
||||||
|
|
||||||
if watermark_free_enabled:
|
if watermark_free_enabled:
|
||||||
# Watermark-free mode: post video and get watermark-free URL
|
# Watermark-free mode: post video and get watermark-free URL
|
||||||
debug_logger.log_info(f"Entering watermark-free mode for task {task_id}")
|
debug_logger.log_info(f"[Watermark-Free] Entering watermark-free mode for task {task_id}")
|
||||||
generation_id = item.get("id")
|
generation_id = item.get("id")
|
||||||
debug_logger.log_info(f"Generation ID: {generation_id}")
|
debug_logger.log_info(f"[Watermark-Free] Generation ID: {generation_id}")
|
||||||
if not generation_id:
|
if not generation_id:
|
||||||
raise Exception("Generation ID not found in video draft")
|
raise Exception("Generation ID not found in video draft")
|
||||||
|
|
||||||
@@ -968,60 +1120,80 @@ class GenerationHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as publish_error:
|
except Exception as publish_error:
|
||||||
# Fallback to normal mode if publish fails
|
# Watermark-free mode failed
|
||||||
|
watermark_free_failed = True
|
||||||
|
import traceback
|
||||||
|
error_traceback = traceback.format_exc()
|
||||||
debug_logger.log_error(
|
debug_logger.log_error(
|
||||||
error_message=f"Watermark-free mode failed: {str(publish_error)}",
|
error_message=f"[Watermark-Free] ❌ FAILED - Error: {str(publish_error)}",
|
||||||
status_code=500,
|
status_code=500,
|
||||||
response_text=str(publish_error)
|
response_text=f"{str(publish_error)}\n\nTraceback:\n{error_traceback}"
|
||||||
)
|
)
|
||||||
if stream:
|
|
||||||
yield self._format_stream_chunk(
|
# Check if fallback is enabled
|
||||||
reasoning_content=f"Warning: Failed to get watermark-free version - {str(publish_error)}\nFalling back to normal video...\n"
|
if watermark_config.fallback_on_failure:
|
||||||
)
|
debug_logger.log_info(f"[Watermark-Free] Fallback enabled, falling back to normal mode (original URL)")
|
||||||
# Use downloadable_url instead of url
|
|
||||||
url = item.get("downloadable_url") or item.get("url")
|
|
||||||
if not url:
|
|
||||||
raise Exception("Video URL not found")
|
|
||||||
if config.cache_enabled:
|
|
||||||
try:
|
|
||||||
cached_filename = await self.file_cache.download_and_cache(url, "video", token_id=token_id)
|
|
||||||
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 cache enabled)
|
|
||||||
if config.cache_enabled:
|
|
||||||
if stream:
|
if stream:
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
|
reasoning_content=f"⚠️ Warning: Failed to get watermark-free version - {str(publish_error)}\nFalling back to normal video...\n"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Fallback disabled, mark task as failed
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"[Watermark-Free] Fallback disabled, marking task as failed",
|
||||||
|
status_code=500,
|
||||||
|
response_text=str(publish_error)
|
||||||
|
)
|
||||||
|
if stream:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"❌ Error: Failed to get watermark-free version - {str(publish_error)}\nFallback is disabled. Task marked as failed.\n"
|
||||||
|
)
|
||||||
|
# Re-raise the exception to mark task as failed
|
||||||
|
raise
|
||||||
|
|
||||||
try:
|
# If watermark-free mode is disabled or failed (with fallback enabled), use normal mode
|
||||||
cached_filename = await self.file_cache.download_and_cache(url, "video", token_id=token_id)
|
if not watermark_free_enabled or (watermark_free_failed and watermark_config.fallback_on_failure):
|
||||||
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
# Normal mode: use downloadable_url instead of url
|
||||||
if stream:
|
url = item.get("downloadable_url") or item.get("url")
|
||||||
|
if not url:
|
||||||
|
raise Exception("Video URL not found in draft")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"Using original URL from draft: {url[:100]}...")
|
||||||
|
|
||||||
|
if config.cache_enabled:
|
||||||
|
# Show appropriate message based on mode
|
||||||
|
if stream and not watermark_free_failed:
|
||||||
|
# Normal mode (watermark-free disabled)
|
||||||
|
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", token_id=token_id)
|
||||||
|
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
||||||
|
if stream:
|
||||||
|
if watermark_free_failed:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Video file cached successfully (fallback mode). Preparing final response...\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="Video file cached successfully. Preparing final response...\n"
|
reasoning_content="Video file cached successfully. Preparing final response...\n"
|
||||||
)
|
)
|
||||||
except Exception as cache_error:
|
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
|
local_url = url
|
||||||
if stream:
|
if stream:
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
|
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Cache disabled
|
||||||
|
local_url = url
|
||||||
|
if stream and not watermark_free_failed:
|
||||||
|
# Normal mode (watermark-free disabled)
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Task completed
|
# Task completed
|
||||||
await self.db.update_task(
|
await self.db.update_task(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Load balancing module"""
|
"""Load balancing module"""
|
||||||
import random
|
import random
|
||||||
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from collections import defaultdict
|
||||||
from ..core.models import Token
|
from ..core.models import Token
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from .token_manager import TokenManager
|
from .token_manager import TokenManager
|
||||||
@@ -9,13 +11,38 @@ from .concurrency_manager import ConcurrencyManager
|
|||||||
from ..core.logger import debug_logger
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
class LoadBalancer:
|
class LoadBalancer:
|
||||||
"""Token load balancer with random selection and image generation lock"""
|
"""Token load balancer with random selection and round-robin polling"""
|
||||||
|
|
||||||
def __init__(self, token_manager: TokenManager, concurrency_manager: Optional[ConcurrencyManager] = None):
|
def __init__(self, token_manager: TokenManager, concurrency_manager: Optional[ConcurrencyManager] = None):
|
||||||
self.token_manager = token_manager
|
self.token_manager = token_manager
|
||||||
self.concurrency_manager = concurrency_manager
|
self.concurrency_manager = concurrency_manager
|
||||||
# Use image timeout from config as lock timeout
|
# Use image timeout from config as lock timeout
|
||||||
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
|
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
|
||||||
|
# Round-robin state: stores last used token_id for each scenario (image/video/default)
|
||||||
|
# Resets to None on restart
|
||||||
|
self._round_robin_state = {"image": None, "video": None, "default": None}
|
||||||
|
self._rr_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def _select_round_robin(self, tokens: list[Token], scenario: str) -> Optional[Token]:
|
||||||
|
"""Select tokens in round-robin order for the given scenario"""
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
tokens_sorted = sorted(tokens, key=lambda t: t.id)
|
||||||
|
|
||||||
|
async with self._rr_lock:
|
||||||
|
last_id = self._round_robin_state.get(scenario)
|
||||||
|
start_idx = 0
|
||||||
|
if last_id is not None:
|
||||||
|
# Find the position of last used token and move to next
|
||||||
|
for idx, token in enumerate(tokens_sorted):
|
||||||
|
if token.id == last_id:
|
||||||
|
start_idx = (idx + 1) % len(tokens_sorted)
|
||||||
|
break
|
||||||
|
selected = tokens_sorted[start_idx]
|
||||||
|
# Update state for next selection
|
||||||
|
self._round_robin_state[scenario] = selected.id
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
async def select_token(self, for_image_generation: bool = False, for_video_generation: bool = False, require_pro: bool = False) -> Optional[Token]:
|
async def select_token(self, for_image_generation: bool = False, for_video_generation: bool = False, require_pro: bool = False) -> Optional[Token]:
|
||||||
"""
|
"""
|
||||||
@@ -89,6 +116,11 @@ class LoadBalancer:
|
|||||||
if not available_tokens:
|
if not available_tokens:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if polling mode is enabled
|
||||||
|
if config.call_logic_mode == "polling":
|
||||||
|
scenario = "image"
|
||||||
|
return await self._select_round_robin(available_tokens, scenario)
|
||||||
|
|
||||||
# Random selection from available tokens
|
# Random selection from available tokens
|
||||||
return random.choice(available_tokens)
|
return random.choice(available_tokens)
|
||||||
else:
|
else:
|
||||||
@@ -100,7 +132,18 @@ class LoadBalancer:
|
|||||||
available_tokens.append(token)
|
available_tokens.append(token)
|
||||||
if not available_tokens:
|
if not available_tokens:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if polling mode is enabled
|
||||||
|
if config.call_logic_mode == "polling":
|
||||||
|
scenario = "video"
|
||||||
|
return await self._select_round_robin(available_tokens, scenario)
|
||||||
|
|
||||||
return random.choice(available_tokens)
|
return random.choice(available_tokens)
|
||||||
else:
|
else:
|
||||||
# For video generation without concurrency manager, no additional filtering
|
# For video generation without concurrency manager, no additional filtering
|
||||||
|
# Check if polling mode is enabled
|
||||||
|
if config.call_logic_mode == "polling":
|
||||||
|
scenario = "video" if for_video_generation else "default"
|
||||||
|
return await self._select_round_robin(active_tokens, scenario)
|
||||||
|
|
||||||
return random.choice(active_tokens)
|
return random.choice(active_tokens)
|
||||||
|
|||||||
136
src/services/pow_service_client.py
Normal file
136
src/services/pow_service_client.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""POW Service Client - External POW service integration"""
|
||||||
|
import json
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
|
from ..core.config import config
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
class POWServiceClient:
|
||||||
|
"""Client for external POW service API"""
|
||||||
|
|
||||||
|
async def get_sentinel_token(self) -> Optional[Tuple[str, str, str]]:
|
||||||
|
"""Get sentinel token from external POW service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (sentinel_token, device_id, user_agent) or None on failure
|
||||||
|
"""
|
||||||
|
# Read configuration dynamically on each call
|
||||||
|
server_url = config.pow_service_server_url
|
||||||
|
api_key = config.pow_service_api_key
|
||||||
|
proxy_enabled = config.pow_service_proxy_enabled
|
||||||
|
proxy_url = config.pow_service_proxy_url if proxy_enabled else None
|
||||||
|
|
||||||
|
if not server_url or not api_key:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service not configured: missing server_url or api_key",
|
||||||
|
status_code=0,
|
||||||
|
response_text="Configuration error",
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Construct API endpoint
|
||||||
|
api_url = f"{server_url.rstrip('/')}/api/pow/token"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
|
||||||
|
|
||||||
|
async with AsyncSession(impersonate="chrome131") as session:
|
||||||
|
response = await session.get(
|
||||||
|
api_url,
|
||||||
|
headers=headers,
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"POW service request failed: {response.status_code}"
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=error_msg,
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if not data.get("success"):
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service returned success=false",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = data.get("token")
|
||||||
|
device_id = data.get("device_id")
|
||||||
|
user_agent = data.get("user_agent")
|
||||||
|
cached = data.get("cached", False)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service returned empty token",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse token to extract device_id if not provided
|
||||||
|
token_data = None
|
||||||
|
if not device_id:
|
||||||
|
try:
|
||||||
|
token_data = json.loads(token)
|
||||||
|
device_id = token_data.get("id")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 记录详细的 token 信息
|
||||||
|
cache_status = "cached" if cached else "fresh"
|
||||||
|
debug_logger.log_info("=" * 100)
|
||||||
|
debug_logger.log_info(f"[POW Service] Token obtained successfully ({cache_status})")
|
||||||
|
debug_logger.log_info(f"[POW Service] Token length: {len(token)}")
|
||||||
|
debug_logger.log_info(f"[POW Service] Device ID: {device_id}")
|
||||||
|
debug_logger.log_info(f"[POW Service] User Agent: {user_agent}")
|
||||||
|
|
||||||
|
# 解析并显示 token 结构
|
||||||
|
if not token_data:
|
||||||
|
try:
|
||||||
|
token_data = json.loads(token)
|
||||||
|
except:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token is not valid JSON")
|
||||||
|
token_data = None
|
||||||
|
|
||||||
|
if token_data:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token structure keys: {list(token_data.keys())}")
|
||||||
|
for key, value in token_data.items():
|
||||||
|
if isinstance(value, str) and len(value) > 100:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token[{key}]: <string, length={len(value)}>")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token[{key}]: {value}")
|
||||||
|
|
||||||
|
debug_logger.log_info("=" * 100)
|
||||||
|
|
||||||
|
return token, device_id, user_agent
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"POW service request exception: {str(e)}",
|
||||||
|
status_code=0,
|
||||||
|
response_text=str(e),
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
pow_service_client = POWServiceClient()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -946,19 +946,21 @@ class TokenManager:
|
|||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool):
|
||||||
"""Update token active status"""
|
"""Update token active status"""
|
||||||
await self.db.update_token_status(token_id, is_active)
|
# When manually changing status, set appropriate disabled_reason
|
||||||
|
disabled_reason = None if is_active else "manual"
|
||||||
|
await self.db.update_token_status(token_id, is_active, disabled_reason)
|
||||||
|
|
||||||
async def enable_token(self, token_id: int):
|
async def enable_token(self, token_id: int):
|
||||||
"""Enable a token and reset error count"""
|
"""Enable a token and reset error count"""
|
||||||
await self.db.update_token_status(token_id, True)
|
await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
|
||||||
# Reset error count when enabling (in token_stats table)
|
# Reset error count when enabling (in token_stats table)
|
||||||
await self.db.reset_error_count(token_id)
|
await self.db.reset_error_count(token_id)
|
||||||
# Clear expired flag when enabling
|
# Clear expired flag when enabling
|
||||||
await self.db.clear_token_expired(token_id)
|
await self.db.clear_token_expired(token_id)
|
||||||
|
|
||||||
async def disable_token(self, token_id: int):
|
async def disable_token(self, token_id: int):
|
||||||
"""Disable a token"""
|
"""Disable a token (manual disable)"""
|
||||||
await self.db.update_token_status(token_id, False)
|
await self.db.update_token_status(token_id, False, "manual")
|
||||||
|
|
||||||
async def test_token(self, token_id: int) -> dict:
|
async def test_token(self, token_id: int) -> dict:
|
||||||
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
||||||
@@ -1048,6 +1050,14 @@ class TokenManager:
|
|||||||
"valid": False,
|
"valid": False,
|
||||||
"message": "Token已过期(token_invalidated)"
|
"message": "Token已过期(token_invalidated)"
|
||||||
}
|
}
|
||||||
|
# Check if error is "Failed to get user info:401"
|
||||||
|
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
|
||||||
|
# Mark token as invalid and disable it
|
||||||
|
await self.db.mark_token_invalid(token_id)
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"message": "Token无效: Token is invalid: Failed to get user info:401"
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"message": f"Token is invalid: {error_msg}"
|
"message": f"Token is invalid: {error_msg}"
|
||||||
@@ -1077,7 +1087,8 @@ class TokenManager:
|
|||||||
admin_config = await self.db.get_admin_config()
|
admin_config = await self.db.get_admin_config()
|
||||||
|
|
||||||
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
||||||
await self.db.update_token_status(token_id, False)
|
# Disable token with error_limit reason
|
||||||
|
await self.db.update_token_status(token_id, False, "error_limit")
|
||||||
|
|
||||||
async def record_success(self, token_id: int, is_video: bool = False):
|
async def record_success(self, token_id: int, is_video: bool = False):
|
||||||
"""Record successful request (reset error count)"""
|
"""Record successful request (reset error count)"""
|
||||||
|
|||||||
95
src/utils/timezone.py
Normal file
95
src/utils/timezone.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Timezone utilities for consistent time handling across the application"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone_offset() -> int:
|
||||||
|
"""Get timezone offset in hours from environment variable or default to UTC+8
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Timezone offset in hours (default: 8 for China/Asia/Shanghai)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return int(os.getenv("TIMEZONE_OFFSET", "8"))
|
||||||
|
except ValueError:
|
||||||
|
return 8
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone() -> timezone:
|
||||||
|
"""Get timezone object based on configured offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
timezone: Timezone object with configured offset
|
||||||
|
"""
|
||||||
|
offset_hours = get_timezone_offset()
|
||||||
|
return timezone(timedelta(hours=offset_hours))
|
||||||
|
|
||||||
|
|
||||||
|
def convert_utc_to_local(utc_time_str: Optional[str]) -> Optional[str]:
|
||||||
|
"""Convert UTC timestamp string to local timezone with ISO format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
utc_time_str: UTC timestamp string (e.g., "2024-01-24 10:30:45")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: ISO formatted timestamp with timezone info (e.g., "2024-01-24T18:30:45+08:00")
|
||||||
|
None: If conversion fails or input is None
|
||||||
|
"""
|
||||||
|
if not utc_time_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse SQLite timestamp (UTC) - handle both with and without 'Z' suffix
|
||||||
|
dt = datetime.fromisoformat(utc_time_str.replace('Z', '+00:00'))
|
||||||
|
|
||||||
|
# If no timezone info, assume UTC
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Convert to local timezone
|
||||||
|
local_tz = get_timezone()
|
||||||
|
dt_local = dt.astimezone(local_tz)
|
||||||
|
|
||||||
|
# Return ISO format with timezone
|
||||||
|
return dt_local.isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
# If conversion fails, return original value
|
||||||
|
print(f"Warning: Failed to convert timestamp '{utc_time_str}': {e}")
|
||||||
|
return utc_time_str
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_local_time() -> datetime:
|
||||||
|
"""Get current time in local timezone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime: Current datetime with local timezone
|
||||||
|
"""
|
||||||
|
return datetime.now(get_timezone())
|
||||||
|
|
||||||
|
|
||||||
|
def format_local_time(dt: Optional[datetime], fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||||
|
"""Format datetime to string in local timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime object to format
|
||||||
|
fmt: Format string (default: "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted time string, or "-" if dt is None
|
||||||
|
"""
|
||||||
|
if not dt:
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert to local timezone if needed
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
local_tz = get_timezone()
|
||||||
|
dt_local = dt.astimezone(local_tz)
|
||||||
|
return dt_local.strftime(fmt)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to format datetime: {e}")
|
||||||
|
return str(dt)
|
||||||
@@ -79,7 +79,27 @@
|
|||||||
<!-- Token 列表 -->
|
<!-- Token 列表 -->
|
||||||
<div class="rounded-lg border border-border bg-background">
|
<div class="rounded-lg border border-border bg-background">
|
||||||
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
<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">
|
||||||
|
<h3 class="text-lg font-semibold">Token 列表</h3>
|
||||||
|
<!-- 状态筛选按钮 -->
|
||||||
|
<div class="relative">
|
||||||
|
<button id="statusFilterBtn" onclick="toggleStatusFilterDropdown()" class="inline-flex items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 h-8 text-sm transition-colors hover:bg-accent hover:border-primary" 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">
|
||||||
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||||
|
</svg>
|
||||||
|
<span id="statusFilterLabel">全部</span>
|
||||||
|
<svg class="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 id="statusFilterDropdown" class="hidden absolute left-0 mt-2 w-32 rounded-md border border-border bg-background shadow-lg z-50">
|
||||||
|
<div class="py-1">
|
||||||
|
<button onclick="selectStatusFilter('')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- 自动刷新AT标签和开关 -->
|
<!-- 自动刷新AT标签和开关 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -223,6 +243,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>清理禁用</span>
|
<span>清理禁用</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="batchDeleteSelected()" 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"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<span>删除选中</span>
|
||||||
|
</button>
|
||||||
<button onclick="openBatchProxyModal()" class="batch-dropdown-item blue" title="批量修改选中Token的代理">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -270,6 +299,7 @@
|
|||||||
<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">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">账户类型</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>
|
||||||
<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>
|
||||||
@@ -344,7 +374,61 @@
|
|||||||
<input id="cfgProxyUrl" 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">
|
<input id="cfgProxyUrl" 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>
|
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="saveProxyConfig()" 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>
|
||||||
|
<label class="text-sm font-medium mb-2 block">测试域名</label>
|
||||||
|
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">用于测试代理连接的目标域名</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button onclick="saveProxyConfig()" 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>
|
||||||
|
<button onclick="testProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-muted hover:bg-muted/80 h-9 px-4 w-full">测试代理</button>
|
||||||
|
</div>
|
||||||
|
<div id="proxyStatusMessage" class="text-xs hidden"></div>
|
||||||
|
<div class="text-xs text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md border border-amber-200 dark:border-amber-900">
|
||||||
|
<strong>⚠️ 提示:</strong>代理测试成功仅表示代理服务器可以正常连接到目标域名,并不能保证代理 IP 所在地区可以使用 Sora 服务。请确保您的代理 IP 位于支持 Sora 的地区。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- POW配置 -->
|
||||||
|
<div class="rounded-lg border border-border bg-background p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">POW配置</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">计算模式</label>
|
||||||
|
<select id="cfgPowMode" onchange="togglePowFields()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="local">本地计算</option>
|
||||||
|
<option value="external">外部服务</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">选择 POW 计算方式</p>
|
||||||
|
</div>
|
||||||
|
<div id="powExternalFields" style="display: none;">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">服务器地址</label>
|
||||||
|
<input id="cfgPowServerUrl" 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:8002">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">外部 POW 服务的地址</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">API 密钥</label>
|
||||||
|
<input id="cfgPowApiKey" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入 API 密钥">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">访问外部 POW 服务的密钥</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgPowProxyEnabled" onchange="togglePowProxyFields()" class="h-4 w-4 rounded border-input">
|
||||||
|
<span class="text-sm font-medium">启用POW代理</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
|
||||||
|
</div>
|
||||||
|
<div id="powProxyUrlField" style="display: none;">
|
||||||
|
<label class="text-sm font-medium mb-2 block">POW代理地址</label>
|
||||||
|
<input id="cfgPowProxyUrl" 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">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="savePowConfig()" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -357,6 +441,25 @@
|
|||||||
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
|
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
|
||||||
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
|
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgTaskRetryEnabled" class="h-4 w-4 rounded border-input">
|
||||||
|
<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="cfgTaskMaxRetries" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3" min="1" max="10">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">任务失败后最多重试的次数(1-10次)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgAutoDisableOn401" class="h-4 w-4 rounded border-input">
|
||||||
|
<span class="text-sm font-medium">遇到401错误自动禁用Token</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">当Token返回401错误时,自动禁用该Token并使用其他Token重试</p>
|
||||||
|
</div>
|
||||||
<button onclick="saveAdminConfig()" 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>
|
<button onclick="saveAdminConfig()" 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,6 +539,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 回退开关 -->
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgFallbackOnFailure" class="h-4 w-4 rounded border-input" checked>
|
||||||
|
<span class="text-sm font-medium">去水印失败后自动回退</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">开启后,去水印失败时自动回退到带水印视频;关闭后,去水印失败将标记任务为失败状态</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 自定义解析配置 -->
|
<!-- 自定义解析配置 -->
|
||||||
<div id="customParseOptions" style="display: none;" class="space-y-4">
|
<div id="customParseOptions" style="display: none;" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -454,6 +566,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 调用逻辑配置 -->
|
||||||
|
<div class="rounded-lg border border-border bg-background p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">账号调用逻辑</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium block">调用模式</label>
|
||||||
|
<select id="cfgCallLogicMode" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
||||||
|
<option value="default">随机轮询</option>
|
||||||
|
<option value="polling">逐个轮询</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="saveCallLogicConfig()" 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>
|
||||||
|
|
||||||
<!-- 调试配置 -->
|
<!-- 调试配置 -->
|
||||||
<div class="rounded-lg border border-border bg-background p-6">
|
<div class="rounded-lg border border-border bg-background p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
|
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
|
||||||
@@ -774,11 +902,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 space-y-4">
|
<div class="p-5 space-y-4">
|
||||||
<div>
|
<div id="jsonFileSection">
|
||||||
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
|
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
|
||||||
<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">
|
<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>
|
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 纯RT导入输入框区域 -->
|
||||||
|
<div id="pureRtSection" class="hidden space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-1.5 block">Refresh Token 列表</label>
|
||||||
|
<textarea id="pureRtInput" rows="3" placeholder="每行一个 RT" class="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-1.5 block">Client ID(可选)</label>
|
||||||
|
<input type="text" id="pureRtClientId" placeholder="留空使用默认值" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-1.5 block">代理地址(可选)</label>
|
||||||
|
<input type="text" id="pureRtProxy" placeholder="http://127.0.0.1:7890" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-1.5 block">图片并发</label>
|
||||||
|
<input type="number" id="pureRtImageConcurrency" value="1" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-1.5 block">视频并发</label>
|
||||||
|
<input type="number" id="pureRtVideoConcurrency" value="3" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">💡 提示:自动刷新并批量导入,并发 -1 表示不限制</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium mb-2 block">选择导入模式</label>
|
<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">
|
<select id="importMode" onchange="updateImportModeHint()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
@@ -786,10 +944,11 @@
|
|||||||
<option value="offline">离线导入(不更新账号状态)</option>
|
<option value="offline">离线导入(不更新账号状态)</option>
|
||||||
<option value="st">优先使用ST导入</option>
|
<option value="st">优先使用ST导入</option>
|
||||||
<option value="rt">优先使用RT导入</option>
|
<option value="rt">优先使用RT导入</option>
|
||||||
|
<option value="pure_rt">纯RT导入</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态(订阅信息、Sora2次数等)</p>
|
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态(订阅信息、Sora2次数等)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
|
<div id="importModeHelpSection" 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>
|
<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="space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
@@ -808,6 +967,10 @@
|
|||||||
<span class="font-medium min-w-[100px]">RT导入:</span>
|
<span class="font-medium min-w-[100px]">RT导入:</span>
|
||||||
<span>适用于只有RT没有AT,自动转换为AT</span>
|
<span>适用于只有RT没有AT,自动转换为AT</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="font-medium min-w-[100px]">纯RT导入:</span>
|
||||||
|
<span>手动输入RT列表(一行一个),自动转换并批量导入</span>
|
||||||
|
</div>
|
||||||
</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">
|
<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问猫猫
|
💡 提示:离线导入后可使用"测试"按钮更新账号信息,功能不稳定有bug问猫猫
|
||||||
@@ -891,21 +1054,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set();
|
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set(),currentStatusFilter='';
|
||||||
const $=(id)=>document.getElementById(id),
|
const $=(id)=>document.getElementById(id),
|
||||||
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
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},
|
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},
|
||||||
|
// 获取token的状态文本和样式
|
||||||
|
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
|
||||||
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
||||||
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
||||||
|
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const status=getTokenStatus(t);statusSet.add(status.text)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
||||||
|
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
||||||
|
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
||||||
|
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
||||||
|
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
||||||
|
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const status=getTokenStatus(t);return status.text===currentStatusFilter})},
|
||||||
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}`},
|
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},
|
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
||||||
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>`},
|
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>`},
|
||||||
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>`},
|
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
||||||
renderTokens=()=>{const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=allTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.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()},
|
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.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 remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const status=getTokenStatus(t);const statusText=status.text;const statusClass=status.class;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">${remainingCount}</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()},
|
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||||
changePage=(page)=>{currentPage=page;renderTokens()},
|
changePage=(page)=>{currentPage=page;renderTokens()},
|
||||||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;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},
|
renderPagination=()=>{const filteredTokens=getFilteredTokens();const totalPages=Math.ceil(filteredTokens.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">共 '+filteredTokens.length+' 条'+(currentStatusFilter?' (筛选后)':' (总数: '+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'),
|
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='3';$('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')},
|
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')},
|
||||||
@@ -927,26 +1098,29 @@
|
|||||||
openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('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=''},
|
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')}},
|
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()},
|
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 isFailed=r.status==='failed';const isAdded=r.status==='added';const statusColor=isFailed?'text-red-600':(isAdded?'text-green-600':'text-blue-600');const statusText=isAdded?'新增':(r.status==='updated'?'更新':'失败');const bgColor=isFailed?'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800':(isAdded?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800');const errorMsg=r.message&&isFailed?r.message:(r.error||'');return`<div class="p-3 rounded-md border ${bgColor}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${errorMsg?`<div class="text-xs text-red-600 dark:text-red-400 mt-1 whitespace-pre-wrap">${errorMsg}</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')},
|
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(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')}},
|
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')}},
|
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')}},
|
batchDeleteDisabled=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}const disabledCount=Array.from(selectedTokenIds).filter(id=>{const token=allTokens.find(t=>t.id===id);return token&&!token.is_active}).length;if(disabledCount===0){showToast('选中的Token中没有禁用的Token','info');return}if(!confirm(`⚠️ 警告\n\n选中了 ${selectedTokenIds.size} 个Token,其中 ${disabledCount} 个是禁用的。\n\n即将删除这 ${disabledCount} 个禁用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')}},
|
||||||
|
batchDeleteSelected=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-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')}},
|
||||||
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()}},
|
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},
|
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')}},
|
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]||''},
|
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),jsonSection=$('jsonFileSection'),pureRtSection=$('pureRtSection'),helpSection=$('importModeHelpSection'),hints={at:'使用AT更新账号状态(订阅信息、Sora2次数等)',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT,然后更新账号状态',rt:'自动将RT转换为AT(并刷新RT),然后更新账号状态',pure_rt:'手动输入RT列表,自动刷新并批量导入'};hint.textContent=hints[mode]||'';if(mode==='pure_rt'){jsonSection.classList.add('hidden');pureRtSection.classList.remove('hidden');helpSection.classList.add('hidden')}else{jsonSection.classList.remove('hidden');pureRtSection.classList.add('hidden');helpSection.classList.remove('hidden')}},
|
||||||
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')}},
|
submitImportTokens=async()=>{const mode=$('importMode').value;const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');if(mode==='pure_rt'){const rtInput=$('pureRtInput').value.trim();if(!rtInput){showToast('请输入 Refresh Token','error');return}const clientId=$('pureRtClientId').value.trim()||'app_LlGpXReQgckcGGUo2JrYvtJK';const proxy=$('pureRtProxy').value.trim()||null;const imageConcurrency=parseInt($('pureRtImageConcurrency').value)||1;const videoConcurrency=parseInt($('pureRtVideoConcurrency').value)||3;const rtList=rtInput.split('\n').map(rt=>rt.trim()).filter(rt=>rt.length>0);if(rtList.length===0){showToast('请输入至少一个 Refresh Token','error');return}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import/pure-rt',{method:'POST',body:JSON.stringify({refresh_tokens:rtList,client_id:clientId,proxy_url:proxy,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){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')}return}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}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}}}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')}},
|
||||||
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)}},
|
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;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('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')}},
|
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});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')}},
|
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')}},
|
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}},
|
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')}},
|
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)}},
|
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)}},
|
||||||
|
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
|
||||||
|
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`✓ ${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`✗ ${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
|
||||||
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')}},
|
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)}},
|
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||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
|
||||||
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')}},
|
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;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,fallback_on_failure:fallbackOnFailure})});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'},
|
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
|
||||||
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
|
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
|
||||||
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
|
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
|
||||||
@@ -956,9 +1130,9 @@
|
|||||||
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')}},
|
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}},
|
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)}},
|
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=>{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)}},
|
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 isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const progress=l.progress||0;const statusText=isProcessing?(progress>0?'生成中':'排队中'):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>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}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()},
|
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')},
|
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.progress||0)>0?'生成中':'排队中'):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')},
|
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')}},
|
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')}},
|
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')}},
|
||||||
@@ -966,7 +1140,15 @@
|
|||||||
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
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')}},
|
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')}},
|
||||||
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||||||
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].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();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
|
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
|
||||||
|
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
|
loadPowConfig=async()=>{try{const r=await apiRequest('/api/pow/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowMode').value=d.config.mode||'local';$('cfgPowServerUrl').value=d.config.server_url||'';$('cfgPowApiKey').value=d.config.api_key||'';$('cfgPowProxyEnabled').checked=d.config.proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.proxy_url||'';togglePowFields();togglePowProxyFields()}else{console.error('POW配置数据格式错误:',d)}}catch(e){console.error('加载POW配置失败:',e)}},
|
||||||
|
savePowConfig=async()=>{try{const mode=$('cfgPowMode').value;const serverUrl=$('cfgPowServerUrl').value.trim();const apiKey=$('cfgPowApiKey').value.trim();const proxyEnabled=$('cfgPowProxyEnabled').checked;const proxyUrl=$('cfgPowProxyUrl').value.trim();if(mode==='external'){if(!serverUrl)return showToast('请输入服务器地址','error');if(!apiKey)return showToast('请输入API密钥','error')}const r=await apiRequest('/api/pow/config',{method:'POST',body:JSON.stringify({mode:mode,server_url:serverUrl||null,api_key:apiKey||null,proxy_enabled:proxyEnabled,proxy_url:proxyUrl||null})});if(!r)return;const d=await r.json();if(d.success){showToast('POW配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
|
loadPowProxyConfig=loadPowConfig,savePowProxyConfig=savePowConfig,loadPowServiceConfig=loadPowConfig,savePowServiceConfig=savePowConfig,
|
||||||
|
togglePowFields=()=>{const mode=$('cfgPowMode').value;const externalFields=$('powExternalFields');if(externalFields){externalFields.style.display=mode==='external'?'block':'none'}},
|
||||||
|
togglePowProxyFields=()=>{const enabled=$('cfgPowProxyEnabled').checked;const proxyUrlField=$('powProxyUrlField');if(proxyUrlField){proxyUrlField.style.display=enabled?'block':'none'}},
|
||||||
|
togglePowServiceFields=()=>{togglePowFields()},
|
||||||
|
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].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();loadPowProxyConfig();loadPowServiceConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
|
||||||
// 自适应生成面板 iframe 高度
|
// 自适应生成面板 iframe 高度
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
@@ -978,6 +1160,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 点击页面其他地方关闭筛选下拉菜单
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const dropdown = $('statusFilterDropdown');
|
||||||
|
const btn = $('statusFilterBtn');
|
||||||
|
if (!dropdown || !btn) return;
|
||||||
|
if (!dropdown.classList.contains('hidden') && !btn.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user