mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-13 00:44:42 +08:00
feat: 支持为单个token设置代理
This commit is contained in:
@@ -63,6 +63,7 @@ class AddTokenRequest(BaseModel):
|
||||
st: Optional[str] = None # Session Token (optional, for storage)
|
||||
rt: Optional[str] = None # Refresh Token (optional, for storage)
|
||||
client_id: Optional[str] = None # Client ID (optional)
|
||||
proxy_url: Optional[str] = None # Proxy URL (optional)
|
||||
remark: Optional[str] = None
|
||||
image_enabled: bool = True # Enable image generation
|
||||
video_enabled: bool = True # Enable video generation
|
||||
@@ -83,6 +84,7 @@ class UpdateTokenRequest(BaseModel):
|
||||
st: Optional[str] = None
|
||||
rt: Optional[str] = None
|
||||
client_id: Optional[str] = None # Client ID
|
||||
proxy_url: Optional[str] = None # Proxy URL
|
||||
remark: Optional[str] = None
|
||||
image_enabled: Optional[bool] = None # Enable image generation
|
||||
video_enabled: Optional[bool] = None # Enable video generation
|
||||
@@ -172,6 +174,7 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
||||
"st": token.st, # 完整的Session Token
|
||||
"rt": token.rt, # 完整的Refresh Token
|
||||
"client_id": token.client_id, # Client ID
|
||||
"proxy_url": token.proxy_url, # Proxy URL
|
||||
"email": token.email,
|
||||
"name": token.name,
|
||||
"remark": token.remark,
|
||||
@@ -214,6 +217,7 @@ async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_
|
||||
st=request.st,
|
||||
rt=request.rt,
|
||||
client_id=request.client_id,
|
||||
proxy_url=request.proxy_url,
|
||||
remark=request.remark,
|
||||
update_if_exists=False,
|
||||
image_enabled=request.image_enabled,
|
||||
@@ -409,7 +413,7 @@ async def update_token(
|
||||
request: UpdateTokenRequest,
|
||||
token: str = Depends(verify_admin_token)
|
||||
):
|
||||
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||
"""Update token (AT, ST, RT, proxy_url, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||
try:
|
||||
await token_manager.update_token(
|
||||
token_id=token_id,
|
||||
@@ -417,6 +421,7 @@ async def update_token(
|
||||
st=request.st,
|
||||
rt=request.rt,
|
||||
client_id=request.client_id,
|
||||
proxy_url=request.proxy_url,
|
||||
remark=request.remark,
|
||||
image_enabled=request.image_enabled,
|
||||
video_enabled=request.video_enabled,
|
||||
|
||||
@@ -197,6 +197,7 @@ class Database:
|
||||
("image_concurrency", "INTEGER DEFAULT -1"),
|
||||
("video_concurrency", "INTEGER DEFAULT -1"),
|
||||
("client_id", "TEXT"),
|
||||
("proxy_url", "TEXT"),
|
||||
]
|
||||
|
||||
for col_name, col_type in columns_to_add:
|
||||
@@ -274,6 +275,7 @@ class Database:
|
||||
st TEXT,
|
||||
rt TEXT,
|
||||
client_id TEXT,
|
||||
proxy_url TEXT,
|
||||
remark TEXT,
|
||||
expiry_time TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
@@ -458,12 +460,12 @@ class Database:
|
||||
"""Add a new token"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute("""
|
||||
INSERT INTO tokens (token, email, username, name, st, rt, client_id, remark, expiry_time, is_active,
|
||||
INSERT INTO tokens (token, email, username, name, st, rt, client_id, proxy_url, remark, expiry_time, is_active,
|
||||
plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code,
|
||||
sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until,
|
||||
image_enabled, video_enabled, image_concurrency, video_concurrency)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (token.token, token.email, "", token.name, token.st, token.rt, token.client_id,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (token.token, token.email, "", token.name, token.st, token.rt, token.client_id, token.proxy_url,
|
||||
token.remark, token.expiry_time, token.is_active,
|
||||
token.plan_type, token.plan_title, token.subscription_end,
|
||||
token.sora2_supported, token.sora2_invite_code,
|
||||
@@ -599,6 +601,7 @@ class Database:
|
||||
st: Optional[str] = None,
|
||||
rt: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
expiry_time: Optional[datetime] = None,
|
||||
plan_type: Optional[str] = None,
|
||||
@@ -608,7 +611,7 @@ class Database:
|
||||
video_enabled: Optional[bool] = None,
|
||||
image_concurrency: Optional[int] = None,
|
||||
video_concurrency: Optional[int] = None):
|
||||
"""Update token (AT, ST, RT, client_id, remark, expiry_time, subscription info, image_enabled, video_enabled)"""
|
||||
"""Update token (AT, ST, RT, client_id, proxy_url, remark, expiry_time, subscription info, image_enabled, video_enabled)"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
# Build dynamic update query
|
||||
updates = []
|
||||
@@ -630,6 +633,10 @@ class Database:
|
||||
updates.append("client_id = ?")
|
||||
params.append(client_id)
|
||||
|
||||
if proxy_url is not None:
|
||||
updates.append("proxy_url = ?")
|
||||
params.append(proxy_url)
|
||||
|
||||
if remark is not None:
|
||||
updates.append("remark = ?")
|
||||
params.append(remark)
|
||||
|
||||
@@ -12,6 +12,7 @@ class Token(BaseModel):
|
||||
st: Optional[str] = None
|
||||
rt: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
proxy_url: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
expiry_time: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
|
||||
@@ -117,20 +117,21 @@ class FileCache:
|
||||
|
||||
return f"{url_hash}{ext}"
|
||||
|
||||
async def download_and_cache(self, url: str, media_type: str) -> str:
|
||||
async def download_and_cache(self, url: str, media_type: str, token_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
Download file from URL and cache it locally
|
||||
|
||||
|
||||
Args:
|
||||
url: File URL to download
|
||||
media_type: 'image' or 'video'
|
||||
|
||||
token_id: Token ID for getting token-specific proxy (optional)
|
||||
|
||||
Returns:
|
||||
Local cache filename
|
||||
"""
|
||||
filename = self._generate_cache_filename(url, media_type)
|
||||
file_path = self.cache_dir / filename
|
||||
|
||||
|
||||
# Check if already cached and not expired
|
||||
if file_path.exists():
|
||||
file_age = time.time() - file_path.stat().st_mtime
|
||||
@@ -143,22 +144,22 @@ class FileCache:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Download file
|
||||
debug_logger.log_info(f"Downloading file from: {url}")
|
||||
|
||||
try:
|
||||
# Get proxy if available
|
||||
# Get proxy if available (token-specific or global)
|
||||
proxy_url = None
|
||||
if self.proxy_manager:
|
||||
proxy_config = await self.proxy_manager.get_proxy_config()
|
||||
if proxy_config.proxy_enabled and proxy_config.proxy_url:
|
||||
proxy_url = proxy_config.proxy_url
|
||||
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
||||
|
||||
# Download with proxy support
|
||||
async with AsyncSession() as session:
|
||||
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
||||
response = await session.get(url, timeout=60, proxies=proxies)
|
||||
kwargs = {"timeout": 60, "impersonate": "chrome"}
|
||||
if proxy_url:
|
||||
kwargs["proxy"] = proxy_url
|
||||
response = await session.get(url, **kwargs)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Download failed: HTTP {response.status_code}")
|
||||
|
||||
@@ -56,20 +56,22 @@ MODEL_CONFIG = {
|
||||
"orientation": "portrait",
|
||||
"n_frames": 450
|
||||
},
|
||||
# Video models with 25s duration (750 frames)
|
||||
# Video models with 25s duration (750 frames) - require Pro subscription
|
||||
"sora2-landscape-25s": {
|
||||
"type": "video",
|
||||
"orientation": "landscape",
|
||||
"n_frames": 750,
|
||||
"model": "sy_8",
|
||||
"size": "small"
|
||||
"size": "small",
|
||||
"require_pro": True
|
||||
},
|
||||
"sora2-portrait-25s": {
|
||||
"type": "video",
|
||||
"orientation": "portrait",
|
||||
"n_frames": 750,
|
||||
"model": "sy_8",
|
||||
"size": "small"
|
||||
"size": "small",
|
||||
"require_pro": True
|
||||
},
|
||||
# Pro video models (require Pro subscription)
|
||||
"sora2pro-landscape-10s": {
|
||||
@@ -491,14 +493,16 @@ class GenerationHandler:
|
||||
n_frames=n_frames,
|
||||
style_id=style_id,
|
||||
model=sora_model,
|
||||
size=video_size
|
||||
size=video_size,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
else:
|
||||
task_id = await self.sora_client.generate_image(
|
||||
prompt, token_obj.token,
|
||||
width=model_config["width"],
|
||||
height=model_config["height"],
|
||||
media_id=media_id
|
||||
media_id=media_id,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
|
||||
# Save task to database
|
||||
@@ -645,7 +649,7 @@ class GenerationHandler:
|
||||
try:
|
||||
if is_video:
|
||||
# Get pending tasks to check progress
|
||||
pending_tasks = await self.sora_client.get_pending_tasks(token)
|
||||
pending_tasks = await self.sora_client.get_pending_tasks(token, token_id=token_id)
|
||||
|
||||
# Find matching task in pending tasks
|
||||
task_found = False
|
||||
@@ -677,7 +681,7 @@ class GenerationHandler:
|
||||
# If task not found in pending tasks, it's completed - fetch from drafts
|
||||
if not task_found:
|
||||
debug_logger.log_info(f"Task {task_id} not found in pending tasks, fetching from drafts...")
|
||||
result = await self.sora_client.get_video_drafts(token)
|
||||
result = await self.sora_client.get_video_drafts(token, token_id=token_id)
|
||||
items = result.get("items", [])
|
||||
|
||||
# Find matching task in drafts
|
||||
@@ -794,7 +798,7 @@ class GenerationHandler:
|
||||
# Cache watermark-free video (if cache enabled)
|
||||
if config.cache_enabled:
|
||||
try:
|
||||
cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
|
||||
cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video", token_id=token_id)
|
||||
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
||||
if stream:
|
||||
yield self._format_stream_chunk(
|
||||
@@ -852,7 +856,7 @@ class GenerationHandler:
|
||||
raise Exception("Video URL not found")
|
||||
if config.cache_enabled:
|
||||
try:
|
||||
cached_filename = await self.file_cache.download_and_cache(url, "video")
|
||||
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
|
||||
@@ -870,7 +874,7 @@ class GenerationHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
cached_filename = await self.file_cache.download_and_cache(url, "video")
|
||||
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:
|
||||
yield self._format_stream_chunk(
|
||||
@@ -906,7 +910,7 @@ class GenerationHandler:
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
else:
|
||||
result = await self.sora_client.get_image_tasks(token)
|
||||
result = await self.sora_client.get_image_tasks(token, token_id=token_id)
|
||||
task_responses = result.get("task_responses", [])
|
||||
|
||||
# Find matching task
|
||||
@@ -936,7 +940,7 @@ class GenerationHandler:
|
||||
if config.cache_enabled:
|
||||
for idx, url in enumerate(urls):
|
||||
try:
|
||||
cached_filename = await self.file_cache.download_and_cache(url, "image")
|
||||
cached_filename = await self.file_cache.download_and_cache(url, "image", token_id=token_id)
|
||||
local_url = f"{base_url}/tmp/{cached_filename}"
|
||||
local_urls.append(local_url)
|
||||
if stream and len(urls) > 1:
|
||||
@@ -1383,7 +1387,8 @@ class GenerationHandler:
|
||||
orientation=model_config["orientation"],
|
||||
n_frames=n_frames,
|
||||
model=sora_model,
|
||||
size=video_size
|
||||
size=video_size,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
debug_logger.log_info(f"Video generation started, task_id: {task_id}")
|
||||
|
||||
|
||||
@@ -5,21 +5,36 @@ from ..core.models import ProxyConfig
|
||||
|
||||
class ProxyManager:
|
||||
"""Proxy configuration manager"""
|
||||
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def get_proxy_url(self) -> Optional[str]:
|
||||
"""Get proxy URL if enabled, otherwise return None"""
|
||||
|
||||
async def get_proxy_url(self, token_id: Optional[int] = None) -> Optional[str]:
|
||||
"""Get proxy URL for a token, with fallback to global proxy
|
||||
|
||||
Args:
|
||||
token_id: Token ID (optional). If provided, returns token-specific proxy if set,
|
||||
otherwise falls back to global proxy.
|
||||
|
||||
Returns:
|
||||
Proxy URL string or None
|
||||
"""
|
||||
# If token_id is provided, try to get token-specific proxy first
|
||||
if token_id is not None:
|
||||
token = await self.db.get_token(token_id)
|
||||
if token and token.proxy_url:
|
||||
return token.proxy_url
|
||||
|
||||
# Fall back to global proxy
|
||||
config = await self.db.get_proxy_config()
|
||||
if config.proxy_enabled and config.proxy_url:
|
||||
return config.proxy_url
|
||||
return None
|
||||
|
||||
|
||||
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
||||
"""Update proxy configuration"""
|
||||
await self.db.update_proxy_config(enabled, proxy_url)
|
||||
|
||||
|
||||
async def get_proxy_config(self) -> ProxyConfig:
|
||||
"""Get proxy configuration"""
|
||||
return await self.db.get_proxy_config()
|
||||
|
||||
@@ -96,7 +96,8 @@ class SoraClient:
|
||||
async def _make_request(self, method: str, endpoint: str, token: str,
|
||||
json_data: Optional[Dict] = None,
|
||||
multipart: Optional[Dict] = None,
|
||||
add_sentinel_token: bool = False) -> Dict[str, Any]:
|
||||
add_sentinel_token: bool = False,
|
||||
token_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Make HTTP request with proxy support
|
||||
|
||||
Args:
|
||||
@@ -106,8 +107,9 @@ class SoraClient:
|
||||
json_data: JSON request body
|
||||
multipart: Multipart form data (for file uploads)
|
||||
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests)
|
||||
token_id: Token ID for getting token-specific proxy (optional)
|
||||
"""
|
||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}"
|
||||
@@ -226,7 +228,7 @@ class SoraClient:
|
||||
return result["id"]
|
||||
|
||||
async def generate_image(self, prompt: str, token: str, width: int = 360,
|
||||
height: int = 360, media_id: Optional[str] = None) -> str:
|
||||
height: int = 360, media_id: Optional[str] = None, token_id: Optional[int] = None) -> str:
|
||||
"""Generate image (text-to-image or image-to-image)"""
|
||||
operation = "remix" if media_id else "simple_compose"
|
||||
|
||||
@@ -250,12 +252,12 @@ class SoraClient:
|
||||
}
|
||||
|
||||
# 生成请求需要添加 sentinel token
|
||||
result = await self._make_request("POST", "/video_gen", token, json_data=json_data, add_sentinel_token=True)
|
||||
result = await self._make_request("POST", "/video_gen", token, json_data=json_data, add_sentinel_token=True, token_id=token_id)
|
||||
return result["id"]
|
||||
|
||||
async def generate_video(self, prompt: str, token: str, orientation: str = "landscape",
|
||||
media_id: Optional[str] = None, n_frames: int = 450, style_id: Optional[str] = None,
|
||||
model: str = "sy_8", size: str = "small") -> str:
|
||||
model: str = "sy_8", size: str = "small", token_id: Optional[int] = None) -> str:
|
||||
"""Generate video (text-to-video or image-to-video)
|
||||
|
||||
Args:
|
||||
@@ -267,6 +269,7 @@ class SoraClient:
|
||||
style_id: Optional style ID
|
||||
model: Model to use (sy_8 for standard, sy_ore for pro)
|
||||
size: Video size (small for standard, large for HD)
|
||||
token_id: Token ID for getting token-specific proxy (optional)
|
||||
"""
|
||||
inpaint_items = []
|
||||
if media_id:
|
||||
@@ -287,24 +290,24 @@ class SoraClient:
|
||||
}
|
||||
|
||||
# 生成请求需要添加 sentinel token
|
||||
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True)
|
||||
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True, token_id=token_id)
|
||||
return result["id"]
|
||||
|
||||
async def get_image_tasks(self, token: str, limit: int = 20) -> Dict[str, Any]:
|
||||
async def get_image_tasks(self, token: str, limit: int = 20, token_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Get recent image generation tasks"""
|
||||
return await self._make_request("GET", f"/v2/recent_tasks?limit={limit}", token)
|
||||
|
||||
async def get_video_drafts(self, token: str, limit: int = 15) -> Dict[str, Any]:
|
||||
"""Get recent video drafts"""
|
||||
return await self._make_request("GET", f"/project_y/profile/drafts?limit={limit}", token)
|
||||
return await self._make_request("GET", f"/v2/recent_tasks?limit={limit}", token, token_id=token_id)
|
||||
|
||||
async def get_pending_tasks(self, token: str) -> list:
|
||||
async def get_video_drafts(self, token: str, limit: int = 15, token_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Get recent video drafts"""
|
||||
return await self._make_request("GET", f"/project_y/profile/drafts?limit={limit}", token, token_id=token_id)
|
||||
|
||||
async def get_pending_tasks(self, token: str, token_id: Optional[int] = None) -> list:
|
||||
"""Get pending video generation tasks
|
||||
|
||||
Returns:
|
||||
List of pending tasks with progress information
|
||||
"""
|
||||
result = await self._make_request("GET", "/nf/pending/v2", token)
|
||||
result = await self._make_request("GET", "/nf/pending/v2", token, token_id=token_id)
|
||||
# The API returns a list directly
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
|
||||
@@ -643,6 +643,7 @@ class TokenManager:
|
||||
st: Optional[str] = None,
|
||||
rt: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
update_if_exists: bool = False,
|
||||
image_enabled: bool = True,
|
||||
@@ -656,6 +657,7 @@ class TokenManager:
|
||||
st: Session Token (optional)
|
||||
rt: Refresh Token (optional)
|
||||
client_id: Client ID (optional)
|
||||
proxy_url: Proxy URL (optional)
|
||||
remark: Remark (optional)
|
||||
update_if_exists: If True, update existing token instead of raising error
|
||||
image_enabled: Enable image generation (default: True)
|
||||
@@ -792,6 +794,7 @@ class TokenManager:
|
||||
st=st,
|
||||
rt=rt,
|
||||
client_id=client_id,
|
||||
proxy_url=proxy_url,
|
||||
remark=remark,
|
||||
expiry_time=expiry_time,
|
||||
is_active=True,
|
||||
@@ -877,12 +880,13 @@ class TokenManager:
|
||||
st: Optional[str] = None,
|
||||
rt: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
image_enabled: Optional[bool] = None,
|
||||
video_enabled: Optional[bool] = None,
|
||||
image_concurrency: Optional[int] = None,
|
||||
video_concurrency: Optional[int] = None):
|
||||
"""Update token (AT, ST, RT, client_id, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||
"""Update token (AT, ST, RT, client_id, proxy_url, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||
# If token (AT) is updated, decode JWT to get new expiry time
|
||||
expiry_time = None
|
||||
if token:
|
||||
@@ -892,7 +896,7 @@ class TokenManager:
|
||||
except Exception:
|
||||
pass # If JWT decode fails, keep expiry_time as None
|
||||
|
||||
await self.db.update_token(token_id, token=token, st=st, rt=rt, client_id=client_id, remark=remark, expiry_time=expiry_time,
|
||||
await self.db.update_token(token_id, token=token, st=st, rt=rt, client_id=client_id, proxy_url=proxy_url, remark=remark, expiry_time=expiry_time,
|
||||
image_enabled=image_enabled, video_enabled=video_enabled,
|
||||
image_concurrency=image_concurrency, video_concurrency=video_concurrency)
|
||||
|
||||
|
||||
@@ -457,6 +457,13 @@
|
||||
<p class="text-xs text-muted-foreground">用于 RT 刷新,留空使用默认 Client ID</p>
|
||||
</div>
|
||||
|
||||
<!-- Proxy URL -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||||
<input id="addTokenProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||||
<p class="text-xs text-muted-foreground">支持 http 和 socks5 代理,留空使用系统设置的代理</p>
|
||||
</div>
|
||||
|
||||
<!-- Remark -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||||
@@ -553,6 +560,13 @@
|
||||
<p class="text-xs text-muted-foreground">用于 RT 刷新,留空使用默认 Client ID</p>
|
||||
</div>
|
||||
|
||||
<!-- Proxy URL -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||||
<input id="editTokenProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||||
<p class="text-xs text-muted-foreground">支持 http 和 socks5 代理,留空使用系统设置的代理</p>
|
||||
</div>
|
||||
|
||||
<!-- Remark -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||||
@@ -681,15 +695,15 @@
|
||||
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=(t.video_enabled&&t.sora2_supported)?`${t.video_count||0}`:'-';return`<tr><td class="py-2.5 px-3">${t.email}</td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3 text-xs">${formatSora2(t)}</td><td class="py-2.5 px-3">${formatSora2Remaining(t)}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('')},
|
||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
||||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('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||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
|
||||
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')},
|
||||
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
||||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('addRTRefreshHint').classList.add('hidden')},
|
||||
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenClientId').value=token.client_id||'';$('editTokenProxyUrl').value=token.proxy_url||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
|
||||
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenProxyUrl').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')},
|
||||
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),proxyUrl=$('editTokenProxyUrl').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
||||
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||
convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||
convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),clientId=$('addTokenClientId').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
|
||||
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),clientId=$('addTokenClientId').value.trim(),proxyUrl=$('addTokenProxyUrl').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
|
||||
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
||||
toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
|
||||
toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
|
||||
|
||||
Reference in New Issue
Block a user