|
|
<!DOCTYPE html> |
|
|
<html lang="zh-CN" class="h-full"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>管理控制台 - Sora2API</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<style> |
|
|
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}} |
|
|
.animate-slide-up{animation:slide-up .3s ease-out} |
|
|
.tab-btn{transition:all .2s ease} |
|
|
</style> |
|
|
<script> |
|
|
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}} |
|
|
</script> |
|
|
</head> |
|
|
<body class="h-full bg-background text-foreground antialiased"> |
|
|
|
|
|
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur"> |
|
|
<div class="mx-auto flex h-14 max-w-7xl items-center px-6"> |
|
|
<div class="mr-4 flex items-baseline gap-3"> |
|
|
<span class="font-bold text-xl">Sora2API</span> |
|
|
</div> |
|
|
<div class="flex flex-1 items-center justify-end gap-3"> |
|
|
<a href="https://github.com/TheSmallHanCat/sora2api" target="_blank" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5" title="GitHub 仓库"> |
|
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> |
|
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> |
|
|
</svg> |
|
|
</a> |
|
|
<button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1"> |
|
|
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> |
|
|
<polyline points="16 17 21 12 16 7"/> |
|
|
<line x1="21" y1="12" x2="9" y2="12"/> |
|
|
</svg> |
|
|
退出 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main class="mx-auto max-w-7xl px-6 py-6"> |
|
|
|
|
|
<div class="border-b border-border mb-6"> |
|
|
<nav class="flex space-x-8"> |
|
|
<button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button> |
|
|
<button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">系统配置</button> |
|
|
<button onclick="switchTab('logs')" id="tabLogs" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">请求日志</button> |
|
|
</nav> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="panelTokens"> |
|
|
|
|
|
<div class="grid gap-4 grid-cols-2 md:grid-cols-5 mb-6"> |
|
|
<div class="rounded-lg border border-border bg-background p-4"> |
|
|
<p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p> |
|
|
<h3 class="text-xl font-bold" id="statTotal">-</h3> |
|
|
</div> |
|
|
<div class="rounded-lg border border-border bg-background p-4"> |
|
|
<p class="text-sm font-medium text-muted-foreground mb-2">活跃 Token</p> |
|
|
<h3 class="text-xl font-bold text-green-600" id="statActive">-</h3> |
|
|
</div> |
|
|
<div class="rounded-lg border border-border bg-background p-4"> |
|
|
<p class="text-sm font-medium text-muted-foreground mb-2">总图片数</p> |
|
|
<h3 class="text-xl font-bold text-blue-600" id="statImages">-</h3> |
|
|
</div> |
|
|
<div class="rounded-lg border border-border bg-background p-4"> |
|
|
<p class="text-sm font-medium text-muted-foreground mb-2">总视频数</p> |
|
|
<h3 class="text-xl font-bold text-purple-600" id="statVideos">-</h3> |
|
|
</div> |
|
|
<div class="rounded-lg border border-border bg-background p-4"> |
|
|
<p class="text-sm font-medium text-muted-foreground mb-2">错误次数</p> |
|
|
<h3 class="text-xl font-bold text-destructive" id="statErrors">-</h3> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="rounded-lg border border-border bg-background"> |
|
|
<div class="flex items-center justify-between gap-4 p-4 border-b border-border"> |
|
|
<h3 class="text-lg font-semibold">Token 列表</h3> |
|
|
<div class="flex items-center gap-3"> |
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
<span class="text-xs text-muted-foreground">自动刷新AT</span> |
|
|
<div class="relative inline-flex items-center group"> |
|
|
<label class="inline-flex items-center cursor-pointer"> |
|
|
<input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer"> |
|
|
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div> |
|
|
</label> |
|
|
|
|
|
<div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"> |
|
|
Token距离过期<24h时自动使用ST或RT刷新AT |
|
|
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新"> |
|
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> |
|
|
</svg> |
|
|
</button> |
|
|
<button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3"> |
|
|
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<line x1="12" y1="5" x2="12" y2="19"/> |
|
|
<line x1="5" y1="12" x2="19" y2="12"/> |
|
|
</svg> |
|
|
<span class="text-sm font-medium">新增</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="relative w-full overflow-auto"> |
|
|
<table class="w-full text-sm"> |
|
|
<thead> |
|
|
<tr class="border-b border-border"> |
|
|
<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">Sora2</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-right align-middle font-medium text-muted-foreground">操作</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="tokenTableBody" class="divide-y divide-border"> |
|
|
|
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="panelSettings" class="hidden"> |
|
|
<div class="grid gap-6 lg:grid-cols-2"> |
|
|
|
|
|
<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 mb-2 block">管理员用户名</label> |
|
|
<input id="cfgAdminUsername" type="text" 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">管理员用户名</p> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">旧密码</label> |
|
|
<input id="cfgOldPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入旧密码"> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">新密码</label> |
|
|
<input id="cfgNewPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新密码"> |
|
|
</div> |
|
|
<button onclick="updateAdminPassword()" 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"> |
|
|
<h3 class="text-lg font-semibold mb-4">API 密钥配置</h3> |
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">当前 API Key</label> |
|
|
<input id="cfgCurrentAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" readonly disabled> |
|
|
<p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">新 API Key</label> |
|
|
<input id="cfgNewAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新的 API Key"> |
|
|
<p class="text-xs text-muted-foreground mt-1">用于客户端调用 API 的密钥</p> |
|
|
</div> |
|
|
<button onclick="updateAPIKey()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">更新 API Key</button> |
|
|
</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="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input"> |
|
|
<span class="text-sm font-medium">启用代理</span> |
|
|
</label> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">代理地址</label> |
|
|
<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> |
|
|
</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> |
|
|
</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 mb-2 block">错误封禁阈值</label> |
|
|
<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> |
|
|
</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> |
|
|
</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="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()"> |
|
|
<span class="text-sm font-medium">启用缓存</span> |
|
|
</label> |
|
|
<p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border"> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label> |
|
|
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400"> |
|
|
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时)</p> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">缓存文件访问域名(请使用当前服务的地址)</label> |
|
|
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com"> |
|
|
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p> |
|
|
</div> |
|
|
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden"> |
|
|
<p class="text-xs text-muted-foreground"> |
|
|
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code> |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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 mb-2 block">图片生成超时时间(秒)</label> |
|
|
<input id="cfgImageTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="300" min="60" max="3600"> |
|
|
<p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label> |
|
|
<input id="cfgVideoTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1500" min="60" max="7200"> |
|
|
<p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p> |
|
|
</div> |
|
|
<button onclick="saveGenerationTimeout()" 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"> |
|
|
<h3 class="text-lg font-semibold mb-4">无水印模式配置</h3> |
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="cfgWatermarkFreeEnabled" class="h-4 w-4 rounded border-input" onchange="toggleWatermarkFreeOptions()"> |
|
|
<span class="text-sm font-medium">开启无水印模式</span> |
|
|
</label> |
|
|
<p class="text-xs text-muted-foreground mt-2">开启后生成的视频将会被发布到sora平台并且提取返回无水印的视频,在缓存到本地后会自动删除发布的视频(需要开启缓存功能)</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="watermarkFreeOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border"> |
|
|
<div> |
|
|
<label class="text-sm font-medium">解析方式</label> |
|
|
<select id="cfgParseMethod" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground" onchange="toggleCustomParseOptions()"> |
|
|
<option value="third_party">第三方解析</option> |
|
|
<option value="custom">自定义解析接口</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="customParseOptions" style="display: none;" class="space-y-4"> |
|
|
<div> |
|
|
<label class="text-sm font-medium">解析服务器地址</label> |
|
|
<input type="text" id="cfgCustomParseUrl" placeholder="请输入解析服务器地址 (例如: http://example.com)" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground"> |
|
|
<p class="text-xs text-muted-foreground mt-1"><a href="https://github.com/tibbar213/sora-downloader" target="_blank" class="text-blue-600 hover:text-blue-800 underline">部署自定义解析服务器</a></p> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-sm font-medium">访问密钥</label> |
|
|
<input type="password" id="cfgCustomParseToken" placeholder="请输入访问密钥" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground"> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button onclick="saveWatermarkFreeConfig()" 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"> |
|
|
<h3 class="text-lg font-semibold mb-4">调试配置</h3> |
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()"> |
|
|
<span class="text-sm font-medium">启用调试模式</span> |
|
|
</label> |
|
|
<p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件,重启生效</p> |
|
|
</div> |
|
|
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800"> |
|
|
<p class="text-xs text-yellow-800 dark:text-yellow-200"> |
|
|
⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="panelLogs" class="hidden"> |
|
|
<div class="rounded-lg border border-border bg-background"> |
|
|
<div class="flex items-center justify-between gap-4 p-4 border-b border-border"> |
|
|
<h3 class="text-lg font-semibold">请求日志</h3> |
|
|
<button onclick="refreshLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新"> |
|
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative w-full overflow-auto max-h-[600px]"> |
|
|
<table class="w-full text-sm"> |
|
|
<thead class="sticky top-0 bg-background"> |
|
|
<tr class="border-b border-border"> |
|
|
<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">Token邮箱</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> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="logsTableBody" class="divide-y divide-border"> |
|
|
|
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<footer class="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground"> |
|
|
<p>© 2025 <a href="https://linux.do/u/thesmallhancat/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">TheSmallHanCat</a> && <a href="https://linux.do/u/tibbar/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">Tibbar</a>. All rights reserved.</p> |
|
|
</footer> |
|
|
</main> |
|
|
|
|
|
|
|
|
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto"> |
|
|
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto"> |
|
|
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background"> |
|
|
<h3 class="text-lg font-semibold">添加 Token</h3> |
|
|
<button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground"> |
|
|
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<line x1="18" y1="6" x2="6" y2="18"/> |
|
|
<line x1="6" y1="6" x2="18" y2="18"/> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto"> |
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label> |
|
|
<textarea id="addTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea> |
|
|
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<div class="flex gap-2"> |
|
|
<textarea id="addTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea> |
|
|
<button onclick="convertST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto"> |
|
|
ST→AT |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<div class="flex gap-2"> |
|
|
<textarea id="addTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea> |
|
|
<button onclick="convertRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto"> |
|
|
RT→AT |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p> |
|
|
<p id="addRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息"> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-3 pt-2 border-t border-border"> |
|
|
<label class="text-sm font-medium">功能开关</label> |
|
|
<div class="space-y-2"> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input"> |
|
|
<span class="text-sm font-medium">启用图片生成</span> |
|
|
</label> |
|
|
</div> |
|
|
<div class="space-y-2"> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input"> |
|
|
<span class="text-sm font-medium">启用视频生成</span> |
|
|
</label> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background"> |
|
|
<button onclick="closeAddModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button> |
|
|
<button id="addTokenBtn" onclick="submitAddToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"> |
|
|
<span id="addTokenBtnText">添加</span> |
|
|
<svg id="addTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto"> |
|
|
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto"> |
|
|
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background"> |
|
|
<h3 class="text-lg font-semibold">编辑 Token</h3> |
|
|
<button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground"> |
|
|
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<line x1="18" y1="6" x2="6" y2="18"/> |
|
|
<line x1="6" y1="6" x2="18" y2="18"/> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto"> |
|
|
<input type="hidden" id="editTokenId"> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label> |
|
|
<textarea id="editTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea> |
|
|
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<div class="flex gap-2"> |
|
|
<textarea id="editTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea> |
|
|
<button onclick="convertEditST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto"> |
|
|
ST→AT |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<div class="flex gap-2"> |
|
|
<textarea id="editTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea> |
|
|
<button onclick="convertEditRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto"> |
|
|
RT→AT |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p> |
|
|
<p id="editRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-2"> |
|
|
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label> |
|
|
<input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息"> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="space-y-3 pt-2 border-t border-border"> |
|
|
<label class="text-sm font-medium">功能开关</label> |
|
|
<div class="space-y-2"> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input"> |
|
|
<span class="text-sm font-medium">启用图片生成</span> |
|
|
</label> |
|
|
</div> |
|
|
<div class="space-y-2"> |
|
|
<label class="inline-flex items-center gap-2 cursor-pointer"> |
|
|
<input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input"> |
|
|
<span class="text-sm font-medium">启用视频生成</span> |
|
|
</label> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background"> |
|
|
<button onclick="closeEditModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button> |
|
|
<button id="editTokenBtn" onclick="submitEditToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"> |
|
|
<span id="editTokenBtnText">保存</span> |
|
|
<svg id="editTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="sora2Modal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"> |
|
|
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl"> |
|
|
<div class="flex items-center justify-between p-5 border-b border-border"> |
|
|
<h3 class="text-lg font-semibold">激活 Sora2</h3> |
|
|
<button onclick="closeSora2Modal()" class="text-muted-foreground hover:text-foreground"> |
|
|
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<line x1="18" y1="6" x2="6" y2="18"/> |
|
|
<line x1="6" y1="6" x2="18" y2="18"/> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
<div class="p-5 space-y-4"> |
|
|
<input type="hidden" id="sora2TokenId"> |
|
|
<div> |
|
|
<label class="text-sm font-medium mb-2 block">Sora2 邀请码</label> |
|
|
<input id="sora2InviteCode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入6位邀请码,例如:0ZSKEG"> |
|
|
<p class="text-xs text-muted-foreground mt-1">输入Sora2邀请码以激活该Token的Sora2功能</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center justify-end gap-3 p-5 border-t border-border"> |
|
|
<button onclick="closeSora2Modal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button> |
|
|
<button id="sora2ActivateBtn" onclick="submitSora2Activate()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"> |
|
|
<span id="sora2ActivateBtnText">激活</span> |
|
|
<svg id="sora2ActivateBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let allTokens=[]; |
|
|
const $=(id)=>document.getElementById(id), |
|
|
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}, |
|
|
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.total_images||0;$('statVideos').textContent=d.total_videos||0;$('statErrors').textContent=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)}}, |
|
|
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}, |
|
|
formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}else{return'-'}}, |
|
|
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`}, |
|
|
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}}, |
|
|
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 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='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('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||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editModal').classList.remove('hidden')}, |
|
|
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editRTRefreshHint').classList.add('hidden')}, |
|
|
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked;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,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});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(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked;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,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});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')}}, |
|
|
deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失败','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}}, |
|
|
copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}}, |
|
|
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')}, |
|
|
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''}, |
|
|
submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活(之前已接受)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}}, |
|
|
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}}, |
|
|
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, |
|
|
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}}, |
|
|
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}}, |
|
|
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}}, |
|
|
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}}, |
|
|
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, |
|
|
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}}, |
|
|
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim();if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, |
|
|
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'}, |
|
|
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'}, |
|
|
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'}, |
|
|
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}}, |
|
|
loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}}, |
|
|
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}}, |
|
|
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}}, |
|
|
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}}, |
|
|
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}}, |
|
|
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${l.duration.toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}}, |
|
|
refreshLogs=async()=>{await loadLogs()}, |
|
|
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}, |
|
|
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'}, |
|
|
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}}; |
|
|
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|