feat: 支持为单个token设置代理

This commit is contained in:
TheSmallHanCat
2025-12-25 19:52:25 +08:00
parent 77a8fbcdb0
commit 1f7630dbed
9 changed files with 111 additions and 56 deletions

View File

@@ -63,6 +63,7 @@ class AddTokenRequest(BaseModel):
st: Optional[str] = None # Session Token (optional, for storage) st: Optional[str] = None # Session Token (optional, for storage)
rt: Optional[str] = None # Refresh Token (optional, for storage) rt: Optional[str] = None # Refresh Token (optional, for storage)
client_id: Optional[str] = None # Client ID (optional) client_id: Optional[str] = None # Client ID (optional)
proxy_url: Optional[str] = None # Proxy URL (optional)
remark: Optional[str] = None remark: Optional[str] = None
image_enabled: bool = True # Enable image generation image_enabled: bool = True # Enable image generation
video_enabled: bool = True # Enable video generation video_enabled: bool = True # Enable video generation
@@ -83,6 +84,7 @@ class UpdateTokenRequest(BaseModel):
st: Optional[str] = None st: Optional[str] = None
rt: Optional[str] = None rt: Optional[str] = None
client_id: Optional[str] = None # Client ID client_id: Optional[str] = None # Client ID
proxy_url: Optional[str] = None # Proxy URL
remark: Optional[str] = None remark: Optional[str] = None
image_enabled: Optional[bool] = None # Enable image generation image_enabled: Optional[bool] = None # Enable image generation
video_enabled: Optional[bool] = None # Enable video 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 "st": token.st, # 完整的Session Token
"rt": token.rt, # 完整的Refresh Token "rt": token.rt, # 完整的Refresh Token
"client_id": token.client_id, # Client ID "client_id": token.client_id, # Client ID
"proxy_url": token.proxy_url, # Proxy URL
"email": token.email, "email": token.email,
"name": token.name, "name": token.name,
"remark": token.remark, "remark": token.remark,
@@ -214,6 +217,7 @@ async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_
st=request.st, st=request.st,
rt=request.rt, rt=request.rt,
client_id=request.client_id, client_id=request.client_id,
proxy_url=request.proxy_url,
remark=request.remark, remark=request.remark,
update_if_exists=False, update_if_exists=False,
image_enabled=request.image_enabled, image_enabled=request.image_enabled,
@@ -409,7 +413,7 @@ async def update_token(
request: UpdateTokenRequest, request: UpdateTokenRequest,
token: str = Depends(verify_admin_token) 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: try:
await token_manager.update_token( await token_manager.update_token(
token_id=token_id, token_id=token_id,
@@ -417,6 +421,7 @@ async def update_token(
st=request.st, st=request.st,
rt=request.rt, rt=request.rt,
client_id=request.client_id, client_id=request.client_id,
proxy_url=request.proxy_url,
remark=request.remark, remark=request.remark,
image_enabled=request.image_enabled, image_enabled=request.image_enabled,
video_enabled=request.video_enabled, video_enabled=request.video_enabled,

View File

@@ -197,6 +197,7 @@ class Database:
("image_concurrency", "INTEGER DEFAULT -1"), ("image_concurrency", "INTEGER DEFAULT -1"),
("video_concurrency", "INTEGER DEFAULT -1"), ("video_concurrency", "INTEGER DEFAULT -1"),
("client_id", "TEXT"), ("client_id", "TEXT"),
("proxy_url", "TEXT"),
] ]
for col_name, col_type in columns_to_add: for col_name, col_type in columns_to_add:
@@ -274,6 +275,7 @@ class Database:
st TEXT, st TEXT,
rt TEXT, rt TEXT,
client_id TEXT, client_id TEXT,
proxy_url TEXT,
remark TEXT, remark TEXT,
expiry_time TIMESTAMP, expiry_time TIMESTAMP,
is_active BOOLEAN DEFAULT 1, is_active BOOLEAN DEFAULT 1,
@@ -458,12 +460,12 @@ class Database:
"""Add a new token""" """Add a new token"""
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(""" 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, plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code,
sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until, sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until,
image_enabled, video_enabled, image_concurrency, video_concurrency) image_enabled, video_enabled, image_concurrency, video_concurrency)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (token.token, token.email, "", token.name, token.st, token.rt, token.client_id, """, (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.remark, token.expiry_time, token.is_active,
token.plan_type, token.plan_title, token.subscription_end, token.plan_type, token.plan_title, token.subscription_end,
token.sora2_supported, token.sora2_invite_code, token.sora2_supported, token.sora2_invite_code,
@@ -599,6 +601,7 @@ class Database:
st: Optional[str] = None, st: Optional[str] = None,
rt: Optional[str] = None, rt: Optional[str] = None,
client_id: Optional[str] = None, client_id: Optional[str] = None,
proxy_url: Optional[str] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
expiry_time: Optional[datetime] = None, expiry_time: Optional[datetime] = None,
plan_type: Optional[str] = None, plan_type: Optional[str] = None,
@@ -608,7 +611,7 @@ class Database:
video_enabled: Optional[bool] = None, video_enabled: Optional[bool] = None,
image_concurrency: Optional[int] = None, image_concurrency: Optional[int] = None,
video_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: async with aiosqlite.connect(self.db_path) as db:
# Build dynamic update query # Build dynamic update query
updates = [] updates = []
@@ -630,6 +633,10 @@ class Database:
updates.append("client_id = ?") updates.append("client_id = ?")
params.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: if remark is not None:
updates.append("remark = ?") updates.append("remark = ?")
params.append(remark) params.append(remark)

View File

@@ -12,6 +12,7 @@ class Token(BaseModel):
st: Optional[str] = None st: Optional[str] = None
rt: Optional[str] = None rt: Optional[str] = None
client_id: Optional[str] = None client_id: Optional[str] = None
proxy_url: Optional[str] = None
remark: Optional[str] = None remark: Optional[str] = None
expiry_time: Optional[datetime] = None expiry_time: Optional[datetime] = None
is_active: bool = True is_active: bool = True

View File

@@ -117,13 +117,14 @@ class FileCache:
return f"{url_hash}{ext}" 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 Download file from URL and cache it locally
Args: Args:
url: File URL to download url: File URL to download
media_type: 'image' or 'video' media_type: 'image' or 'video'
token_id: Token ID for getting token-specific proxy (optional)
Returns: Returns:
Local cache filename Local cache filename
@@ -148,17 +149,17 @@ class FileCache:
debug_logger.log_info(f"Downloading file from: {url}") debug_logger.log_info(f"Downloading file from: {url}")
try: try:
# Get proxy if available # Get proxy if available (token-specific or global)
proxy_url = None proxy_url = None
if self.proxy_manager: if self.proxy_manager:
proxy_config = await self.proxy_manager.get_proxy_config() proxy_url = await self.proxy_manager.get_proxy_url(token_id)
if proxy_config.proxy_enabled and proxy_config.proxy_url:
proxy_url = proxy_config.proxy_url
# Download with proxy support # Download with proxy support
async with AsyncSession() as session: async with AsyncSession() as session:
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None kwargs = {"timeout": 60, "impersonate": "chrome"}
response = await session.get(url, timeout=60, proxies=proxies) if proxy_url:
kwargs["proxy"] = proxy_url
response = await session.get(url, **kwargs)
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Download failed: HTTP {response.status_code}") raise Exception(f"Download failed: HTTP {response.status_code}")

View File

@@ -56,20 +56,22 @@ MODEL_CONFIG = {
"orientation": "portrait", "orientation": "portrait",
"n_frames": 450 "n_frames": 450
}, },
# Video models with 25s duration (750 frames) # Video models with 25s duration (750 frames) - require Pro subscription
"sora2-landscape-25s": { "sora2-landscape-25s": {
"type": "video", "type": "video",
"orientation": "landscape", "orientation": "landscape",
"n_frames": 750, "n_frames": 750,
"model": "sy_8", "model": "sy_8",
"size": "small" "size": "small",
"require_pro": True
}, },
"sora2-portrait-25s": { "sora2-portrait-25s": {
"type": "video", "type": "video",
"orientation": "portrait", "orientation": "portrait",
"n_frames": 750, "n_frames": 750,
"model": "sy_8", "model": "sy_8",
"size": "small" "size": "small",
"require_pro": True
}, },
# Pro video models (require Pro subscription) # Pro video models (require Pro subscription)
"sora2pro-landscape-10s": { "sora2pro-landscape-10s": {
@@ -491,14 +493,16 @@ class GenerationHandler:
n_frames=n_frames, n_frames=n_frames,
style_id=style_id, style_id=style_id,
model=sora_model, model=sora_model,
size=video_size size=video_size,
token_id=token_obj.id
) )
else: else:
task_id = await self.sora_client.generate_image( task_id = await self.sora_client.generate_image(
prompt, token_obj.token, prompt, token_obj.token,
width=model_config["width"], width=model_config["width"],
height=model_config["height"], height=model_config["height"],
media_id=media_id media_id=media_id,
token_id=token_obj.id
) )
# Save task to database # Save task to database
@@ -645,7 +649,7 @@ class GenerationHandler:
try: try:
if is_video: if is_video:
# Get pending tasks to check progress # 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 # Find matching task in pending tasks
task_found = False task_found = False
@@ -677,7 +681,7 @@ class GenerationHandler:
# If task not found in pending tasks, it's completed - fetch from drafts # If task not found in pending tasks, it's completed - fetch from drafts
if not task_found: if not task_found:
debug_logger.log_info(f"Task {task_id} not found in pending tasks, fetching from drafts...") 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", []) items = result.get("items", [])
# Find matching task in drafts # Find matching task in drafts
@@ -794,7 +798,7 @@ class GenerationHandler:
# Cache watermark-free video (if cache enabled) # Cache watermark-free video (if cache enabled)
if config.cache_enabled: if config.cache_enabled:
try: 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}" local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream: if stream:
yield self._format_stream_chunk( yield self._format_stream_chunk(
@@ -852,7 +856,7 @@ class GenerationHandler:
raise Exception("Video URL not found") raise Exception("Video URL not found")
if config.cache_enabled: if config.cache_enabled:
try: 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}" local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
except Exception as cache_error: except Exception as cache_error:
local_url = url local_url = url
@@ -870,7 +874,7 @@ class GenerationHandler:
) )
try: 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}" local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream: if stream:
yield self._format_stream_chunk( yield self._format_stream_chunk(
@@ -906,7 +910,7 @@ class GenerationHandler:
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
return return
else: 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", []) task_responses = result.get("task_responses", [])
# Find matching task # Find matching task
@@ -936,7 +940,7 @@ class GenerationHandler:
if config.cache_enabled: if config.cache_enabled:
for idx, url in enumerate(urls): for idx, url in enumerate(urls):
try: 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_url = f"{base_url}/tmp/{cached_filename}"
local_urls.append(local_url) local_urls.append(local_url)
if stream and len(urls) > 1: if stream and len(urls) > 1:
@@ -1383,7 +1387,8 @@ class GenerationHandler:
orientation=model_config["orientation"], orientation=model_config["orientation"],
n_frames=n_frames, n_frames=n_frames,
model=sora_model, 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}") debug_logger.log_info(f"Video generation started, task_id: {task_id}")

View File

@@ -9,8 +9,23 @@ class ProxyManager:
def __init__(self, db: Database): def __init__(self, db: Database):
self.db = db self.db = db
async def get_proxy_url(self) -> Optional[str]: async def get_proxy_url(self, token_id: Optional[int] = None) -> Optional[str]:
"""Get proxy URL if enabled, otherwise return None""" """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() config = await self.db.get_proxy_config()
if config.proxy_enabled and config.proxy_url: if config.proxy_enabled and config.proxy_url:
return config.proxy_url return config.proxy_url

View File

@@ -96,7 +96,8 @@ class SoraClient:
async def _make_request(self, method: str, endpoint: str, token: str, async def _make_request(self, method: str, endpoint: str, token: str,
json_data: Optional[Dict] = None, json_data: Optional[Dict] = None,
multipart: 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 """Make HTTP request with proxy support
Args: Args:
@@ -106,8 +107,9 @@ class SoraClient:
json_data: JSON request body json_data: JSON request body
multipart: Multipart form data (for file uploads) multipart: Multipart form data (for file uploads)
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests) 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 = { headers = {
"Authorization": f"Bearer {token}" "Authorization": f"Bearer {token}"
@@ -226,7 +228,7 @@ class SoraClient:
return result["id"] return result["id"]
async def generate_image(self, prompt: str, token: str, width: int = 360, 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)""" """Generate image (text-to-image or image-to-image)"""
operation = "remix" if media_id else "simple_compose" operation = "remix" if media_id else "simple_compose"
@@ -250,12 +252,12 @@ class SoraClient:
} }
# 生成请求需要添加 sentinel token # 生成请求需要添加 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"] return result["id"]
async def generate_video(self, prompt: str, token: str, orientation: str = "landscape", 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, 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) """Generate video (text-to-video or image-to-video)
Args: Args:
@@ -267,6 +269,7 @@ class SoraClient:
style_id: Optional style ID style_id: Optional style ID
model: Model to use (sy_8 for standard, sy_ore for pro) model: Model to use (sy_8 for standard, sy_ore for pro)
size: Video size (small for standard, large for HD) size: Video size (small for standard, large for HD)
token_id: Token ID for getting token-specific proxy (optional)
""" """
inpaint_items = [] inpaint_items = []
if media_id: if media_id:
@@ -287,24 +290,24 @@ class SoraClient:
} }
# 生成请求需要添加 sentinel token # 生成请求需要添加 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"] 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""" """Get recent image generation tasks"""
return await self._make_request("GET", f"/v2/recent_tasks?limit={limit}", token) return await self._make_request("GET", f"/v2/recent_tasks?limit={limit}", token, token_id=token_id)
async def get_video_drafts(self, token: str, limit: int = 15) -> Dict[str, Any]: async def get_video_drafts(self, token: str, limit: int = 15, token_id: Optional[int] = None) -> Dict[str, Any]:
"""Get recent video drafts""" """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"/project_y/profile/drafts?limit={limit}", token, token_id=token_id)
async def get_pending_tasks(self, token: str) -> list: async def get_pending_tasks(self, token: str, token_id: Optional[int] = None) -> list:
"""Get pending video generation tasks """Get pending video generation tasks
Returns: Returns:
List of pending tasks with progress information 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 # The API returns a list directly
return result if isinstance(result, list) else [] return result if isinstance(result, list) else []

View File

@@ -643,6 +643,7 @@ class TokenManager:
st: Optional[str] = None, st: Optional[str] = None,
rt: Optional[str] = None, rt: Optional[str] = None,
client_id: Optional[str] = None, client_id: Optional[str] = None,
proxy_url: Optional[str] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
update_if_exists: bool = False, update_if_exists: bool = False,
image_enabled: bool = True, image_enabled: bool = True,
@@ -656,6 +657,7 @@ class TokenManager:
st: Session Token (optional) st: Session Token (optional)
rt: Refresh Token (optional) rt: Refresh Token (optional)
client_id: Client ID (optional) client_id: Client ID (optional)
proxy_url: Proxy URL (optional)
remark: Remark (optional) remark: Remark (optional)
update_if_exists: If True, update existing token instead of raising error update_if_exists: If True, update existing token instead of raising error
image_enabled: Enable image generation (default: True) image_enabled: Enable image generation (default: True)
@@ -792,6 +794,7 @@ class TokenManager:
st=st, st=st,
rt=rt, rt=rt,
client_id=client_id, client_id=client_id,
proxy_url=proxy_url,
remark=remark, remark=remark,
expiry_time=expiry_time, expiry_time=expiry_time,
is_active=True, is_active=True,
@@ -877,12 +880,13 @@ class TokenManager:
st: Optional[str] = None, st: Optional[str] = None,
rt: Optional[str] = None, rt: Optional[str] = None,
client_id: Optional[str] = None, client_id: Optional[str] = None,
proxy_url: Optional[str] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
image_enabled: Optional[bool] = None, image_enabled: Optional[bool] = None,
video_enabled: Optional[bool] = None, video_enabled: Optional[bool] = None,
image_concurrency: Optional[int] = None, image_concurrency: Optional[int] = None,
video_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 # If token (AT) is updated, decode JWT to get new expiry time
expiry_time = None expiry_time = None
if token: if token:
@@ -892,7 +896,7 @@ class TokenManager:
except Exception: except Exception:
pass # If JWT decode fails, keep expiry_time as None 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_enabled=image_enabled, video_enabled=video_enabled,
image_concurrency=image_concurrency, video_concurrency=video_concurrency) image_concurrency=image_concurrency, video_concurrency=video_concurrency)

View File

@@ -457,6 +457,13 @@
<p class="text-xs text-muted-foreground">用于 RT 刷新,留空使用默认 Client ID</p> <p class="text-xs text-muted-foreground">用于 RT 刷新,留空使用默认 Client ID</p>
</div> </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 --> <!-- Remark -->
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label> <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> <p class="text-xs text-muted-foreground">用于 RT 刷新,留空使用默认 Client ID</p>
</div> </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 --> <!-- Remark -->
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label> <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('')}, 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()}, refreshTokens=async()=>{await loadTokens();await loadStats()},
openAddModal=()=>$('addModal').classList.remove('hidden'), 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')}, 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||'';$('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')},
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')}, 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(),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')}}, 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')}}, 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')}}, 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')}}, 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')}}, 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')}}, 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')}}, 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')}}, 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')}},