Files
flow2api/static/manage.html

1219 lines
138 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>管理控制台 - Flow2API</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}
.line-clamp-2{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
</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">Flow2API</span>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<a href="https://github.com/TheSmallHanCat/flow2api" 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">
<!-- Tab 导航 -->
<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>
<a href="/test" id="tabTestPage" class="tab-btn inline-flex items-center border-b-2 border-transparent text-sm font-medium py-3 px-1">测试页面</a>
</nav>
</div>
<!-- Token 管理面板 -->
<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>
<!-- Token 列表 -->
<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">
<!-- 自动刷新AT标签和开关 -->
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">自动刷新AT</span>
<div class="relative inline-flex items-center group">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer">
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<!-- 悬浮提示 -->
<div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Token距离过期<1h时自动使用ST刷新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="exportTokens()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 h-8 px-3" title="导出所有Token">
<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">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="text-sm font-medium">导出</span>
</button>
<button onclick="openImportModal()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 h-8 px-3" title="导入Token">
<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">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="text-sm font-medium">导入</span>
</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-x-auto overflow-y-visible">
<table class="w-full table-auto text-sm">
<thead>
<tr class="border-b border-border">
<th class="h-10 px-2.5 text-left align-middle font-medium text-muted-foreground">邮箱</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">状态</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">过期时间</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">余额</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">类型</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">项目ID</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">图片</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">视频</th>
<th class="h-10 px-2.5 text-center align-middle font-medium text-muted-foreground">错误</th>
<th class="h-10 w-[184px] px-2.5 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 flex flex-col">
<h3 class="text-lg font-semibold mb-4">安全配置</h3>
<div class="space-y-4 flex-1">
<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>
</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 mt-4">修改密码</button>
</div>
<!-- API 密钥配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
<div class="space-y-4 flex-1">
<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>
</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 mt-4">更新 API Key</button>
</div>
<!-- 代理配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">代理配置</h3>
<div class="space-y-4 flex-1">
<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>
<div class="pt-3 border-t border-border">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgMediaProxyEnabled" class="h-4 w-4 rounded border-input" onchange="toggleMediaProxyInput()">
<span class="text-sm font-medium">媒体上传下载代理</span>
</label>
<p class="text-xs text-muted-foreground mt-1">启用后,图片上传与图片/视频缓存下载可单独走该代理</p>
</div>
<div id="mediaProxyUrlInput" class="hidden">
<label class="text-sm font-medium mb-2 block">媒体上传下载代理地址</label>
<input id="cfgMediaProxyUrl" 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:7897 或 socks5://127.0.0.1:1080">
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-4">
<button id="btnTestProxy" onclick="testProxyConfig()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-4">测试代理</button>
<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">保存配置</button>
</div>
<p id="proxyTestResult" class="text-xs text-muted-foreground mt-2">测试目标:<code class="bg-muted px-1 py-0.5 rounded">https://labs.google/</code></p>
</div>
<!-- 生成配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">生成配置</h3>
<div class="space-y-4 flex-1">
<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>
<div>
<label class="text-sm font-medium mb-2 block">最大重试次数</label>
<input id="cfgMaxRetries" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3" min="1">
<p class="text-xs text-muted-foreground mt-1">生成、上传、轮询等请求失败时的最大重试次数,最小值为 1</p>
</div>
</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 mt-4">保存配置</button>
</div>
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">Token 轮询配置</h3>
<div class="space-y-4 flex-1">
<div>
<label class="text-sm font-medium mb-2 block">轮询模式</label>
<select id="cfgCallLogicMode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="default">随机轮询</option>
<option value="polling">顺序轮询</option>
</select>
<p class="text-xs text-muted-foreground mt-1">随机轮询使用默认负载优先策略顺序轮询会按可用Token的稳定顺序依次使用全部轮完后再开始下一轮。</p>
</div>
</div>
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full mt-4">保存配置</button>
</div>
<!-- 错误处理配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
<div class="space-y-4 flex-1">
<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>
</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 mt-4">保存配置</button>
</div>
<!-- 缓存配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">缓存配置</h3>
<div class="space-y-4 flex-1">
<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="0" max="86400">
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间范围0-86400 秒,其中 0 表示永不过期,不自动删除缓存文件</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>
</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 mt-4">保存配置</button>
</div>
<!-- 插件连接配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">插件连接配置</h3>
<div class="space-y-4 flex-1">
<div>
<label class="text-sm font-semibold mb-2 block">连接接口</label>
<div class="flex gap-2">
<input id="cfgPluginConnectionUrl" type="text" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="加载中...">
<button onclick="copyConnectionUrl()" class="inline-flex items-center justify-center rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4">复制</button>
</div>
<p class="text-xs text-muted-foreground mt-1">Chrome扩展插件需要配置此接口地址</p>
</div>
<div>
<label class="text-sm font-semibold mb-2 block">连接Token</label>
<div class="flex gap-2">
<input id="cfgPluginConnectionToken" type="text" class="flex h-9 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="留空自动生成">
<button onclick="generateRandomToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4">随机</button>
<button onclick="copyConnectionToken()" class="inline-flex items-center justify-center rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4">复制</button>
</div>
<p class="text-xs text-muted-foreground mt-1">用于验证Chrome扩展插件的身份留空将自动生成随机token</p>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgAutoEnableOnUpdate" class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">更新token时自动启用</span>
</label>
<p class="text-xs text-muted-foreground mt-2">当插件更新token时如果该token被禁用则自动启用它</p>
</div>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>使用说明:</strong>安装Chrome扩展后将连接接口和Token配置到插件中插件会自动提取Google Labs的cookie并更新到系统
</p>
</div>
</div>
<button onclick="savePluginConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full mt-4">保存配置</button>
</div>
<!-- 验证码配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">验证码配置</h3>
<div class="space-y-4 flex-1">
<div>
<label class="text-sm font-medium mb-2 block">打码方式</label>
<select id="cfgCaptchaMethod" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" onchange="toggleCaptchaOptions()">
<option value="yescaptcha">YesCaptcha打码</option>
<option value="capmonster">CapMonster打码</option>
<option value="ezcaptcha">EzCaptcha打码</option>
<option value="capsolver">CapSolver打码</option>
<option value="browser">有头浏览器打码</option>
<option value="personal">内置浏览器打码</option>
<option value="remote_browser">远程有头打码</option>
</select>
<p class="text-xs text-muted-foreground mt-1">选择验证码获取方式</p>
</div>
<!-- YesCaptcha配置选项 -->
<div id="yescaptchaOptions" class="space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">YesCaptcha API密钥</label>
<input id="cfgYescaptchaApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="请输入YesCaptcha API密钥">
<p class="text-xs text-muted-foreground mt-1">用于自动获取reCAPTCHA验证码留空则不使用验证码</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">YesCaptcha API地址</label>
<input id="cfgYescaptchaBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://api.yescaptcha.com">
<p class="text-xs text-muted-foreground mt-1">YesCaptcha服务地址默认https://api.yescaptcha.com</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">YesCaptcha Type</label>
<select id="cfgYescaptchaTaskType" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="RecaptchaV3TaskProxyless">RecaptchaV3TaskProxyless - 20 POINTS</option>
<option value="RecaptchaV3TaskProxylessM1">RecaptchaV3TaskProxylessM1 - 25 POINTS</option>
<option value="RecaptchaV3TaskProxylessM1S7">RecaptchaV3TaskProxylessM1S7 - 30 POINTS (minScore 0.7)</option>
<option value="RecaptchaV3TaskProxylessM1S9">RecaptchaV3TaskProxylessM1S9 - 35 POINTS (minScore 0.9)</option>
</select>
<p class="text-xs text-muted-foreground mt-1">S7/S9 会随 createTask 强制提交 minScore 0.7/0.9。</p>
</div>
</div>
<!-- CapMonster配置选项 -->
<div id="capmonsterOptions" class="hidden space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">CapMonster API密钥</label>
<input id="cfgCapmonsterApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="请输入CapMonster API密钥">
<p class="text-xs text-muted-foreground mt-1">用于自动获取reCAPTCHA验证码</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">CapMonster API地址</label>
<input id="cfgCapmonsterBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://api.capmonster.cloud">
<p class="text-xs text-muted-foreground mt-1">CapMonster服务地址默认https://api.capmonster.cloud</p>
</div>
</div>
<!-- EzCaptcha配置选项 -->
<div id="ezcaptchaOptions" class="hidden space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">EzCaptcha API密钥</label>
<input id="cfgEzcaptchaApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="请输入EzCaptcha API密钥">
<p class="text-xs text-muted-foreground mt-1">用于自动获取reCAPTCHA验证码</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">EzCaptcha API地址</label>
<input id="cfgEzcaptchaBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://api.ez-captcha.com">
<p class="text-xs text-muted-foreground mt-1">EzCaptcha服务地址默认https://api.ez-captcha.com</p>
</div>
</div>
<!-- CapSolver配置选项 -->
<div id="capsolverOptions" class="hidden space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">CapSolver API密钥</label>
<input id="cfgCapsolverApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="请输入CapSolver API密钥">
<p class="text-xs text-muted-foreground mt-1">用于自动获取reCAPTCHA验证码</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">CapSolver API地址</label>
<input id="cfgCapsolverBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://api.capsolver.com">
<p class="text-xs text-muted-foreground mt-1">CapSolver服务地址默认https://api.capsolver.com</p>
</div>
</div>
<!-- 浏览器打码配置选项 -->
<div id="browserCaptchaOptions" class="hidden space-y-4">
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>浏览器打码:</strong>使用Playwright自动化浏览器获取验证码无需额外配置但会占用更多系统资源
</p>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgBrowserProxyEnabled" class="h-4 w-4 rounded border-input" onchange="toggleBrowserProxyInput()">
<span class="text-sm font-medium">启用代理</span>
</label>
<p class="text-xs text-muted-foreground mt-2">为有头浏览器配置独立代理</p>
</div>
<div id="browserProxyUrlInput" class="hidden">
<label class="text-sm font-medium mb-2 block">代理地址</label>
<input id="cfgBrowserProxyUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://host:port 或 socks5://host:port">
<p class="text-xs text-muted-foreground mt-1">
<strong>支持:</strong>HTTP/HTTPS/SOCKS5/SOCKS5H 代理,均支持带认证<br>
示例:<code class="bg-muted px-1 py-0.5 rounded">http://user:pass@proxy.com:8080</code><code class="bg-muted px-1 py-0.5 rounded">socks5://user:pass@proxy.com:1080</code>
</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">浏览器数量</label>
<input id="cfgBrowserCount" type="number" min="1" max="20" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1" value="1">
<p class="text-xs text-muted-foreground mt-1">同时启动的浏览器实例数量每个浏览器只开1个标签页请求轮询分配</p>
</div>
</div>
<!-- 内置浏览器打码配置选项 -->
<div id="personalOptions" class="hidden space-y-4">
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>内置浏览器打码:</strong>使用nodriver自动化浏览器获取验证码支持标签页复用性能更优
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium mb-2 block">浏览器实例数量</label>
<input id="cfgPersonalBrowserCount" type="number" min="1" max="20" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1" value="1">
<p class="text-xs text-muted-foreground mt-1">
personal 模式真实并行浏览器数。<br>
多实例会增加端口和内存占用,建议按机器资源逐步调大。
</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">单 Token 项目池大小</label>
<input id="cfgPersonalProjectPoolSize" type="number" min="1" max="50" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="4" value="4">
<p class="text-xs text-muted-foreground mt-1">
只影响该 Token 可轮换的 project_id 数量,不决定打码标签页数。<br>
打码标签页由全局共享池统一复用。
</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">最大标签页数量</label>
<input id="cfgPersonalMaxTabs" type="number" min="1" max="50" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="5" value="5">
<p class="text-xs text-muted-foreground mt-1">
决定 personal 模式最多保留多少个共享打码标签页。<br>
并发打码能力主要看这里,不够就优先调大这个值。
</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">重置码数</label>
<input id="cfgPersonalFreshRestartEveryNSolves" type="number" min="0" max="999" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="10" value="10">
<p class="text-xs text-muted-foreground mt-1">
成功获取多少个码后清理并重启浏览器。<br>
0 表示禁用,默认 10。
</p>
</div>
</div>
<div>
<label class="text-sm font-medium mb-2 block">标签空闲超时(秒)</label>
<input id="cfgPersonalIdleTTL" type="number" min="60" max="3600" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="600" value="600">
<p class="text-xs text-muted-foreground mt-1">标签页空闲多久后自动回收默认600秒(10分钟)</p>
</div>
<div class="pt-3 border-t border-border">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgPersonalProxyEnabled" class="h-4 w-4 rounded border-input" onchange="togglePersonalProxyInput()">
<span class="text-sm font-medium">启用代理</span>
</label>
<p class="text-xs text-muted-foreground mt-2">为内置浏览器配置独立代理(优先级高于全局请求代理)</p>
</div>
<div id="personalProxyUrlInput" class="hidden">
<label class="text-sm font-medium mb-2 block">代理地址</label>
<input id="cfgPersonalProxyUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://host:port 或 socks5://host:port">
<p class="text-xs text-muted-foreground mt-1">
<strong>支持:</strong>HTTP/HTTPS/SOCKS5/SOCKS5H 代理,均支持带认证<br>
示例:<code class="bg-muted px-1 py-0.5 rounded">http://user:pass@proxy.com:8080</code><code class="bg-muted px-1 py-0.5 rounded">socks5://user:pass@proxy.com:1080</code>
</p>
</div>
</div>
<!-- 远程有头打码配置选项 -->
<div id="remoteBrowserOptions" class="hidden space-y-4">
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>远程有头打码:</strong>通过 HTTP 调用独立打码服务获取 token并在请求结束后回调 finish/error
</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">远程服务 Base URL</label>
<input id="cfgRemoteBrowserBaseUrl" type="text" 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:8060">
<p class="text-xs text-muted-foreground mt-1">示例:<code class="bg-muted px-1 py-0.5 rounded">http://127.0.0.1:8060</code></p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">远程服务 API Key</label>
<input id="cfgRemoteBrowserApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="fcs_xxx">
</div>
<div>
<label class="text-sm font-medium mb-2 block">远程请求超时(秒)</label>
<input id="cfgRemoteBrowserTimeout" type="number" min="5" max="300" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="60" value="60">
</div>
</div>
</div>
<div class="mt-4">
<button onclick="saveCaptchaConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4">保存配置</button>
</div>
</div>
<!-- 调试配置 -->
<div class="rounded-lg border border-border bg-background p-6 flex flex-col">
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
<div class="space-y-4 flex-1">
<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 class="text-xs text-muted-foreground text-center mt-4 h-9 flex items-center justify-center">✅ 开关状态自动保存</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>
<div class="flex gap-2">
<button onclick="clearAllLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-red-50 hover:text-red-700 h-8 px-3 text-sm" title="清空日志">
<svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
清空
</button>
<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>
<div class="relative w-full overflow-auto max-h-[600px]">
<table class="w-full table-auto 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">&#25805;&#20316;</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Token&#37038;&#31665;</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">&#29366;&#24577;</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">&#36827;&#24230;</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">&#29366;&#24577;&#30721;</th>
<th class="h-10 w-[17rem] 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">&#32791;&#26102;(&#31186;)</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">&#26102;&#38388;</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">&#35814;&#24773;</th>
</tr>
</thead>
<tbody id="logsTableBody" class="divide-y divide-border">
<!-- 动态填充 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 日志详情模态框 -->
<div id="logDetailModal" 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-3xl shadow-xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">日志详情</h3>
<button onclick="closeLogDetailModal()" 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 overflow-y-auto">
<div id="logDetailContent" class="space-y-4">
<!-- 动态填充 -->
</div>
</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>
<!-- 添加 Token 模态框 -->
<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">
<!-- Session Token -->
<div class="space-y-2">
<label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
<textarea id="addTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token保存时将自动转换为 Access Token</p>
</div>
<!-- Remark -->
<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>
<!-- Project ID -->
<div class="space-y-2">
<label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="addTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则系统自动生成">
<p class="text-xs text-muted-foreground">如果已有Project ID可直接输入,留空则创建新项目</p>
</div>
<!-- Project Name -->
<div class="space-y-2">
<label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="addTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则自动生成 (如: Jan 01 - 12:00)">
</div>
<!-- Captcha Proxy -->
<div class="space-y-2">
<label class="text-sm font-medium">打码代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="addTokenCaptchaProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="例如 http://user:pass@host:port">
<p class="text-xs text-muted-foreground">仅覆盖当前 Token 的浏览器打码代理,留空则使用全局打码代理</p>
</div>
<!-- 功能开关 -->
<div class="space-y-3 pt-2 border-t border-border">
<label class="text-sm font-medium">功能开关</label>
<div class="space-y-2">
<div class="flex items-center gap-3">
<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>
<input type="number" id="addTokenImageConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-3">
<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>
<input type="number" id="addTokenVideoConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</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>
<!-- 编辑 Token 模态框 -->
<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">
<!-- Session Token -->
<div class="space-y-2">
<label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
<textarea id="editTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token保存时将自动转换为 Access Token</p>
</div>
<!-- Remark -->
<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>
<!-- Project ID -->
<div class="space-y-2">
<label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="editTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则保持原有值">
<p class="text-xs text-muted-foreground">修改Project ID会更新Token使用的项目</p>
</div>
<!-- Project Name -->
<div class="space-y-2">
<label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="editTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则保持原有值">
</div>
<!-- Captcha Proxy -->
<div class="space-y-2">
<label class="text-sm font-medium">打码代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
<input id="editTokenCaptchaProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="例如 http://user:pass@host:port">
<p class="text-xs text-muted-foreground">填写后优先覆盖全局打码代理,清空后恢复走全局打码代理</p>
</div>
<!-- 功能开关 -->
<div class="space-y-3 pt-2 border-t border-border">
<label class="text-sm font-medium">功能开关</label>
<div class="space-y-2">
<div class="flex items-center gap-3">
<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>
<input type="number" id="editTokenImageConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-3">
<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>
<input type="number" id="editTokenVideoConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</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>
<!-- Token 导入模态框 -->
<div id="importModal" 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">导入 Token</h3>
<button onclick="closeImportModal()" 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">
<div>
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
<input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
</div>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
<button onclick="closeImportModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
<button id="importBtn" onclick="submitImportTokens()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
<span id="importBtnText">导入</span>
<svg id="importBtnSpinner" 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.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
formatExpiry=exp=>{if(!exp)return'<span class="text-muted-foreground">-</span>';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});const hours=Math.floor(diff/36e5);if(diff<0)return`<span class="text-red-600 font-medium" title="已过期">已过期</span>`;if(hours<1)return`<span class="text-red-600 font-medium" title="${dateStr} ${timeStr}">${Math.floor(diff/6e4)}分钟</span>`;if(hours<24)return`<span class="text-orange-600 font-medium" title="${dateStr} ${timeStr}">${hours}小时</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600" title="${dateStr} ${timeStr}">${days}天</span>`;return`<span class="text-muted-foreground" title="${dateStr} ${timeStr}">${days}天</span>`},
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'-'}},
formatAccountType=(tier)=>{if(tier==='PAYGATE_TIER_NOT_PAID'||!tier){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700">Free</span>`}if(tier==='PAYGATE_TIER_ONE'){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700">Pro</span>`}if(tier==='PAYGATE_TIER_TWO'){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-purple-50 text-purple-700">Ult</span>`}return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-amber-50 text-amber-700" title="${tier}">${tier}</span>`},
formatProjectNameDisplay=name=>{if(!name)return'<span class="text-muted-foreground">-</span>';const text=String(name);const match=text.match(/^(.*?)(?:\s+(P\d+))$/);if(match){return`<div class="flex flex-col items-center justify-center gap-1 leading-none"><span class="text-xs font-medium text-foreground whitespace-nowrap">${escapeLogHtml(match[1])}</span><span class="inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">${escapeLogHtml(match[2])}</span></div>`}return`<div class="text-center text-xs font-medium text-foreground whitespace-nowrap">${escapeLogHtml(text)}</div>`},
formatProjectIdDisplay=projectId=>{if(!projectId)return'<span class="text-muted-foreground">-</span>';const text=String(projectId);const short=text.length>8?`${text.substring(0,8)}...`:text;return`<button onclick="copyProjectId('${projectId}')" class="inline-flex items-center justify-center rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-mono font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-800" title="点击复制项目ID&#10;${escapeLogHtml(text)}">${escapeLogHtml(short)}</button>`},
renderTokenActions=t=>{const toggleLabel=t.is_active?'禁用':'启用';return`<div class="inline-flex flex-nowrap items-center justify-end gap-1"><button onclick="refreshTokenAT(${t.id})" class="inline-flex h-7 items-center justify-center rounded-md px-2 text-xs font-medium hover:bg-blue-50 hover:text-blue-700">刷新AT</button><button onclick="openEditModal(${t.id})" class="inline-flex h-7 items-center justify-center rounded-md px-2 text-xs font-medium hover:bg-green-50 hover:text-green-700">详情</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex h-7 items-center justify-center rounded-md px-2 text-xs font-medium hover:bg-accent">${toggleLabel}</button><button onclick="deleteToken(${t.id})" class="inline-flex h-7 items-center justify-center rounded-md px-2 text-xs font-medium hover:bg-destructive/10 hover:text-destructive">删除</button></div>`},
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const creditsDisplay=t.credits!==undefined?`${t.credits}`:'-';const accountTypeDisplay=formatAccountType(t.user_paygate_tier);const projectIdDisplay=formatProjectIdDisplay(t.current_project_id);const expiryDisplay=formatExpiry(t.at_expires);const tokenActions=renderTokenActions(t);return`<tr class="hover:bg-muted/20 transition-colors"><td class="py-3 px-2.5 align-middle"><div class="max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-foreground" title="${escapeLogHtml(t.email||'')}">${escapeLogHtml(t.email||'-')}</div></td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap"><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-3 px-2.5 text-center text-xs align-middle whitespace-nowrap">${expiryDisplay}</td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap"><button onclick="refreshTokenCredits(${t.id})" class="inline-flex items-center justify-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-800" title="点击刷新余额"><span>${creditsDisplay}</span><svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"/><path d="M15 4.5l3.5 3.5L22 4.5"/></svg></button></td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap">${accountTypeDisplay}</td><td class="py-3 px-2.5 text-center text-xs align-middle whitespace-nowrap">${projectIdDisplay}</td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap"><span class="text-sm font-medium">${imageDisplay}</span></td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap"><span class="text-sm font-medium">${videoDisplay}</span></td><td class="py-3 px-2.5 text-center align-middle whitespace-nowrap"><span class="text-sm font-medium ${Number(t.error_count||0)>0?'text-red-600':'text-foreground'}">${t.error_count||0}</span></td><td class="py-3 px-2.5 text-right align-middle whitespace-nowrap">${tokenActions}</td></tr>`}).join('')},
refreshTokenCredits=async(id)=>{try{showToast('正在刷新余额...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-credits`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`余额刷新成功: ${d.credits}`,'success');await refreshTokens()}else{showToast('刷新余额失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('刷新余额失败: '+e.message,'error')}},
refreshTokenAT=async(id)=>{try{showToast('正在更新AT...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-at`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){const expiresDate=d.token.at_expires?new Date(d.token.at_expires):null;const expiresStr=expiresDate?expiresDate.toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).replace(/\//g,'-'):'未知';showToast(`AT更新成功! 新过期时间: ${expiresStr}`,'success');await refreshTokens()}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
refreshTokens=async()=>{await loadTokens();await loadStats()},
openAddModal=()=>$('addModal').classList.remove('hidden'),
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenST').value='';$('addTokenRemark').value='';$('addTokenProjectId').value='';$('addTokenProjectName').value='';$('addTokenCaptchaProxyUrl').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1'},
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenST').value=token.st||'';$('editTokenRemark').value=token.remark||'';$('editTokenProjectId').value=token.current_project_id||'';$('editTokenProjectName').value=token.current_project_name||'';$('editTokenCaptchaProxyUrl').value=token.captcha_proxy_url||'';$('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='';$('editTokenST').value='';$('editTokenRemark').value='';$('editTokenProjectId').value='';$('editTokenProjectName').value='';$('editTokenCaptchaProxyUrl').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value=''},
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),st=$('editTokenST').value.trim(),remark=$('editTokenRemark').value.trim(),projectId=$('editTokenProjectId').value.trim(),projectName=$('editTokenProjectName').value.trim(),captchaProxyUrl=$('editTokenCaptchaProxyUrl').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(!st)return showToast('请输入 Session 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({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,captcha_proxy_url:captchaProxyUrl,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')}},
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')}},
submitAddToken=async()=>{const st=$('addTokenST').value.trim(),remark=$('addTokenRemark').value.trim(),projectId=$('addTokenProjectId').value.trim(),projectName=$('addTokenProjectName').value.trim(),captchaProxyUrl=$('addTokenCaptchaProxyUrl').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!st)return showToast('请输入 Session Token','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({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,captcha_proxy_url:captchaProxyUrl,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){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')}},
copyProjectId=async(projectId)=>{if(!projectId){showToast('没有可复制的Project ID','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(projectId);showToast(`Project ID已复制: ${projectId}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=projectId;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(`Project ID已复制: ${projectId}`,'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=''},
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,is_active:t.is_active,captcha_proxy_url:t.captcha_proxy_url||'',image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();const msg=`导入成功!新增: ${d.added||0}, 更新: ${d.updated||0}`;showToast(msg,'success')}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
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||'';$('cfgMediaProxyEnabled').checked=d.media_proxy_enabled||false;$('cfgMediaProxyUrl').value=d.media_proxy_url||'';toggleMediaProxyInput()}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(),media_proxy_enabled:$('cfgMediaProxyEnabled').checked,media_proxy_url:$('cfgMediaProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
updateProxyTestResult=(message,isSuccess)=>{const el=$('proxyTestResult');if(!el)return;el.textContent=message;el.className=`text-xs mt-2 ${isSuccess?'text-green-600':'text-red-600'}`},
testProxyConfig=async()=>{const tests=[];const requestProxyUrl=$('cfgProxyUrl').value.trim();const mediaProxyUrl=$('cfgMediaProxyUrl').value.trim();const testUrl='https://labs.google/';if(requestProxyUrl)tests.push({name:'请求代理',proxy_url:requestProxyUrl});if(mediaProxyUrl&&mediaProxyUrl!==requestProxyUrl)tests.push({name:'媒体代理',proxy_url:mediaProxyUrl});if(tests.length===0){updateProxyTestResult('请先填写至少一个代理地址',false);showToast('请先填写代理地址','error');return}const btn=$('btnTestProxy');if(btn){btn.disabled=true;btn.textContent='测试中...'}try{const results=[];for(const item of tests){const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({proxy_url:item.proxy_url,test_url:testUrl})});if(!r){results.push({name:item.name,success:false,message:'请求失败'});continue}const d=await r.json();results.push({name:item.name,success:!!d.success,message:d.message||'未知结果',status_code:d.status_code,elapsed_ms:d.elapsed_ms,final_url:d.final_url})}const allPass=results.every(x=>x.success);const summary=results.map(x=>`${x.name}: ${x.success?'✅':'❌'} ${x.message}${x.status_code?` (HTTP ${x.status_code})`:''}${x.elapsed_ms!==undefined?` ${x.elapsed_ms}ms`:''}`).join(' | ');updateProxyTestResult(summary,allPass);showToast(allPass?'代理测试通过':'代理测试未全部通过',allPass?'success':'error')}catch(e){updateProxyTestResult('代理测试失败: '+e.message,false);showToast('代理测试失败: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='测试代理'}}},
toggleMediaProxyInput=()=>{const enabled=$('cfgMediaProxyEnabled').checked;$('mediaProxyUrlInput').classList.toggle('hidden',!enabled)},
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;const maxRetries=d.config.max_retries||3;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);console.log('设置最大重试次数:',maxRetries);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;$('cfgMaxRetries').value=maxRetries;console.log('生成配置加载成功')}else{console.error('生成配置数据格式错误:',d)}}catch(e){console.error('加载生成配置失败:',e);showToast('加载生成配置失败: '+e.message,'error')}},
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('轮询模式配置数据格式错误:',d)}}catch(e){console.error('加载轮询模式配置失败:',e);showToast('加载轮询模式配置失败: '+e.message,'error')}},
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeoutInput=$('cfgCacheTimeout').value.trim(),timeout=timeoutInput===''?7200:parseInt(timeoutInput,10),baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(Number.isNaN(timeout)||timeout<0||timeout>86400)return showToast('缓存超时时间必须在 0-86400 秒之间0 表示不删除','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,maxRetries=parseInt($('cfgMaxRetries').value)||3;console.log('保存生成配置:',{imageTimeout,videoTimeout,maxRetries});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');if(maxRetries<1)return showToast('最大重试次数不能小于 1','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout,max_retries:maxRetries})});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')}},
saveCallLogicConfig=async()=>{const callMode=$('cfgCallLogicMode').value||'default';try{const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:callMode})});if(!r)return;const d=await r.json();if(d.success){showToast('Token轮询配置保存成功','success');await loadCallLogicConfig()}else{showToast(d.message||'保存失败','error')}}catch(e){console.error('保存轮询模式配置失败:',e);showToast('保存轮询模式配置失败: '+e.message,'error')}},
toggleCaptchaOptions=()=>{const method=$('cfgCaptchaMethod').value;$('yescaptchaOptions').style.display=method==='yescaptcha'?'block':'none';$('capmonsterOptions').classList.toggle('hidden',method!=='capmonster');$('ezcaptchaOptions').classList.toggle('hidden',method!=='ezcaptcha');$('capsolverOptions').classList.toggle('hidden',method!=='capsolver');$('browserCaptchaOptions').classList.toggle('hidden',method!=='browser');$('personalOptions').classList.toggle('hidden',method!=='personal');$('remoteBrowserOptions').classList.toggle('hidden',method!=='remote_browser')},
toggleBrowserProxyInput=()=>{const enabled=$('cfgBrowserProxyEnabled').checked;$('browserProxyUrlInput').classList.toggle('hidden',!enabled)},
togglePersonalProxyInput=()=>{const enabled=$('cfgPersonalProxyEnabled').checked;$('personalProxyUrlInput').classList.toggle('hidden',!enabled)},
loadCaptchaConfig=async()=>{try{console.log('开始加载验证码配置...');const r=await apiRequest('/api/captcha/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('验证码配置数据:',d);const freshRestart=d.browser_personal_fresh_restart_every_n_solves===undefined?10:d.browser_personal_fresh_restart_every_n_solves;$('cfgCaptchaMethod').value=d.captcha_method||'yescaptcha';$('cfgYescaptchaApiKey').value=d.yescaptcha_api_key||'';$('cfgYescaptchaBaseUrl').value=d.yescaptcha_base_url||'https://api.yescaptcha.com';$('cfgYescaptchaTaskType').value=d.yescaptcha_task_type||'RecaptchaV3TaskProxylessM1';$('cfgCapmonsterApiKey').value=d.capmonster_api_key||'';$('cfgCapmonsterBaseUrl').value=d.capmonster_base_url||'https://api.capmonster.cloud';$('cfgEzcaptchaApiKey').value=d.ezcaptcha_api_key||'';$('cfgEzcaptchaBaseUrl').value=d.ezcaptcha_base_url||'https://api.ez-captcha.com';$('cfgCapsolverApiKey').value=d.capsolver_api_key||'';$('cfgCapsolverBaseUrl').value=d.capsolver_base_url||'https://api.capsolver.com';$('cfgRemoteBrowserBaseUrl').value=d.remote_browser_base_url||'';$('cfgRemoteBrowserApiKey').value=d.remote_browser_api_key||'';$('cfgRemoteBrowserTimeout').value=d.remote_browser_timeout||60;$('cfgBrowserProxyEnabled').checked=d.browser_proxy_enabled||false;$('cfgBrowserProxyUrl').value=d.browser_proxy_url||'';$('cfgPersonalProxyEnabled').checked=d.browser_proxy_enabled||false;$('cfgPersonalProxyUrl').value=d.browser_proxy_url||'';$('cfgBrowserCount').value=d.browser_count||1;$('cfgPersonalBrowserCount').value=d.browser_count||1;$('cfgPersonalProjectPoolSize').value=d.personal_project_pool_size||4;$('cfgPersonalMaxTabs').value=d.personal_max_resident_tabs||5;$('cfgPersonalFreshRestartEveryNSolves').value=freshRestart;$('cfgPersonalIdleTTL').value=d.personal_idle_tab_ttl_seconds||600;toggleCaptchaOptions();toggleBrowserProxyInput();togglePersonalProxyInput();console.log('验证码配置加载成功')}catch(e){console.error('加载验证码配置失败:',e);showToast('加载验证码配置失败: '+e.message,'error')}},
saveCaptchaConfig=async()=>{const method=$('cfgCaptchaMethod').value,yesApiKey=$('cfgYescaptchaApiKey').value.trim(),yesBaseUrl=$('cfgYescaptchaBaseUrl').value.trim(),yesTaskType=$('cfgYescaptchaTaskType').value,capApiKey=$('cfgCapmonsterApiKey').value.trim(),capBaseUrl=$('cfgCapmonsterBaseUrl').value.trim(),ezApiKey=$('cfgEzcaptchaApiKey').value.trim(),ezBaseUrl=$('cfgEzcaptchaBaseUrl').value.trim(),solverApiKey=$('cfgCapsolverApiKey').value.trim(),solverBaseUrl=$('cfgCapsolverBaseUrl').value.trim(),remoteBaseUrl=$('cfgRemoteBrowserBaseUrl').value.trim(),remoteApiKey=$('cfgRemoteBrowserApiKey').value.trim(),remoteTimeout=parseInt($('cfgRemoteBrowserTimeout').value)||60,browserProxyEnabled=$('cfgBrowserProxyEnabled').checked,browserProxyUrl=$('cfgBrowserProxyUrl').value.trim(),personalProxyEnabled=$('cfgPersonalProxyEnabled').checked,personalProxyUrl=$('cfgPersonalProxyUrl').value.trim(),browserCount=parseInt($('cfgBrowserCount').value)||1,personalBrowserCount=parseInt($('cfgPersonalBrowserCount').value)||1,personalProjectPoolSize=parseInt($('cfgPersonalProjectPoolSize').value)||4,personalMaxTabs=parseInt($('cfgPersonalMaxTabs').value)||5,personalFreshRestartEveryNSolves=Math.max(0,parseInt($('cfgPersonalFreshRestartEveryNSolves').value)||0),personalIdleTTL=parseInt($('cfgPersonalIdleTTL').value)||600;const finalProxyEnabled=method==='personal'?personalProxyEnabled:browserProxyEnabled;const finalProxyUrl=method==='personal'?personalProxyUrl:browserProxyUrl;const finalBrowserCount=method==='personal'?personalBrowserCount:browserCount;console.log('保存验证码配置:',{method,yesApiKey,yesBaseUrl,yesTaskType,capApiKey,capBaseUrl,ezApiKey,ezBaseUrl,solverApiKey,solverBaseUrl,remoteBaseUrl,remoteApiKey,remoteTimeout,browserProxyEnabled,browserProxyUrl,personalProxyEnabled,personalProxyUrl,finalProxyEnabled,finalProxyUrl,browserCount,personalBrowserCount,finalBrowserCount,personalProjectPoolSize,personalMaxTabs,personalFreshRestartEveryNSolves,personalIdleTTL});try{const r=await apiRequest('/api/captcha/config',{method:'POST',body:JSON.stringify({captcha_method:method,yescaptcha_api_key:yesApiKey,yescaptcha_base_url:yesBaseUrl,yescaptcha_task_type:yesTaskType,capmonster_api_key:capApiKey,capmonster_base_url:capBaseUrl,ezcaptcha_api_key:ezApiKey,ezcaptcha_base_url:ezBaseUrl,capsolver_api_key:solverApiKey,capsolver_base_url:solverBaseUrl,remote_browser_base_url:remoteBaseUrl,remote_browser_api_key:remoteApiKey,remote_browser_timeout:remoteTimeout,browser_proxy_enabled:finalProxyEnabled,browser_proxy_url:finalProxyUrl,browser_count:finalBrowserCount,personal_project_pool_size:personalProjectPoolSize,personal_max_resident_tabs:personalMaxTabs,browser_personal_fresh_restart_every_n_solves:personalFreshRestartEveryNSolves,personal_idle_tab_ttl_seconds:personalIdleTTL})});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 loadCaptchaConfig()}else{console.error('保存失败:',d);showToast(d.message||'保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
loadPluginConfig=async()=>{try{const r=await apiRequest('/api/plugin/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPluginConnectionUrl').value=d.config.connection_url||'';$('cfgPluginConnectionToken').value=d.config.connection_token||'';$('cfgAutoEnableOnUpdate').checked=d.config.auto_enable_on_update||false}}catch(e){console.error('加载插件配置失败:',e);showToast('加载插件配置失败: '+e.message,'error')}},
savePluginConfig=async()=>{const token=$('cfgPluginConnectionToken').value.trim();const autoEnable=$('cfgAutoEnableOnUpdate').checked;try{const r=await apiRequest('/api/plugin/config',{method:'POST',body:JSON.stringify({connection_token:token,auto_enable_on_update:autoEnable})});if(!r)return;const d=await r.json();if(d.success){showToast('插件配置保存成功','success');await loadPluginConfig()}else{showToast(d.message||'保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
copyConnectionUrl=()=>{const url=$('cfgPluginConnectionUrl').value;if(!url){showToast('连接接口为空','error');return}navigator.clipboard.writeText(url).then(()=>showToast('连接接口已复制','success')).catch(()=>showToast('复制失败','error'))},
copyConnectionToken=()=>{const token=$('cfgPluginConnectionToken').value;if(!token){showToast('连接Token为空','error');return}navigator.clipboard.writeText(token).then(()=>showToast('连接Token已复制','success')).catch(()=>showToast('复制失败','error'))},
generateRandomToken=()=>{const chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';let token='';for(let i=0;i<32;i++){token+=chars.charAt(Math.floor(Math.random()*chars.length))}$('cfgPluginConnectionToken').value=token;showToast('随机Token已生成','success')},
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)}},
formatLogStatus=l=>{const statusText=(l.status_text||'').trim();if(statusText){const map={started:'\u5df2\u542f\u52a8',token_selected:'\u5df2\u9009\u4e2d\u8d26\u53f7',token_ready:'\u51c6\u5907\u751f\u6210\u73af\u5883',project_ready:'\u9879\u76ee\u5df2\u5c31\u7eea',uploading_images:'\u4e0a\u4f20\u53c2\u8003\u56fe\u4e2d',solving_image_captcha:'\u56fe\u7247\u6253\u7801\u9a8c\u8bc1\u4e2d',submitting_image:'\u56fe\u7247\u63d0\u4ea4\u4e2d',image_generated:'\u56fe\u7247\u751f\u6210\u5b8c\u6210',preparing_video:'\u51c6\u5907\u89c6\u9891\u4efb\u52a1',submitting_video:'\u89c6\u9891\u63d0\u4ea4\u4e2d',video_submitted:'\u89c6\u9891\u4efb\u52a1\u5df2\u63d0\u4ea4',video_polling:'\u89c6\u9891\u751f\u6210\u4e2d',caching_image:'\u7f13\u5b58\u56fe\u7247\u4e2d',caching_video:'\u7f13\u5b58\u89c6\u9891\u4e2d',completed:'\u5df2\u5b8c\u6210',failed:'\u5931\u8d25',processing:'\u5904\u7406\u4e2d',upsampling_2k:'\u6b63\u5728\u653e\u5927\u52302K',upsampling_4k:'\u6b63\u5728\u653e\u5927\u52304K',upsampling_1080p:'\u6b63\u5728\u653e\u5927\u52301080P'};return map[statusText]||statusText}if(l.status_code===102)return'\u5904\u7406\u4e2d';if(l.status_code===200)return'\u5df2\u5b8c\u6210';if(l.status_code&&l.status_code>=400)return'\u5931\u8d25';return'-'},
formatLogStatusClass=l=>{const statusText=formatLogStatus(l);if(statusText==='\u5904\u7406\u4e2d')return'bg-amber-50 text-amber-700';if(statusText==='\u5df2\u5b8c\u6210')return'bg-green-50 text-green-700';if(statusText==='\u5931\u8d25')return'bg-red-50 text-red-700';return'bg-gray-100 text-gray-700'},
formatLogProgress=l=>{if(l.progress===null||l.progress===undefined||l.progress==='')return'-';const progress=Number(l.progress);return Number.isFinite(progress)?`${Math.max(0,Math.min(100,progress))}%`:'-'},
getLogOperationLabel=l=>{const operation=String(l&&l.operation||'').trim();if(operation==='generate_image')return'图片';if(operation==='generate_video')return'视频';return''},
formatLogOutcome=l=>{const statusCode=Number(l&&l.status_code);if(statusCode===200){const operationLabel=getLogOperationLabel(l);return operationLabel?`${operationLabel}结果已返回`:'已返回结果'}if(statusCode===102)return'处理中';const errorSummary=extractLogErrorSummary(l);if(errorSummary)return truncateLogText(errorSummary,96);return statusCode>=400?'请求失败':'-'},
formatLogOutcomeClass=l=>{if((l.status_code||0)>=400)return'text-red-600';if(l.status_code===200)return'text-green-700';if(l.status_code===102)return'text-amber-700';return'text-muted-foreground'},
startLogsAutoRefresh=()=>{stopLogsAutoRefresh()},
stopLogsAutoRefresh=()=>{if(window.logsAutoRefreshTimer){clearInterval(window.logsAutoRefreshTimer);window.logsAutoRefreshTimer=null}},
loadLogs=async(opts={})=>{const silent=!!opts.silent;try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;window.logDetailCache=window.logDetailCache||Object.create(null);window.logListMeta=Object.create(null);logs.forEach(l=>window.logListMeta[l.id]={updated_at:l.updated_at,created_at:l.created_at,status_code:l.status_code,progress:l.progress,status_text:l.status_text,error_summary:l.error_summary||''});Object.keys(window.logDetailCache).forEach(key=>{const meta=window.logListMeta[key];const cached=window.logDetailCache[key];if(!meta||!cached||cached.updated_at!==meta.updated_at)delete window.logDetailCache[key]});const tb=$('logsTableBody');if(!logs.length){tb.innerHTML='<tr><td colspan="9" class="py-8 px-3 text-center text-sm text-muted-foreground">\u6682\u65e0\u65e5\u5fd7</td></tr>';return}tb.innerHTML=logs.map(l=>{const statusText=formatLogStatus(l),progressText=formatLogProgress(l),outcomeText=formatLogOutcome(l),outcomePreview=truncateLogText(outcomeText,96),statusCodeClass=l.status_code===102?'bg-amber-50 text-amber-700':(l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700');return`<tr><td class="py-2.5 px-3">${escapeLogHtml(l.operation||'-')}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${escapeLogHtml(l.token_email||'\u672a\u77e5')}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${formatLogStatusClass(l)}">${escapeLogHtml(statusText)}</span></td><td class="py-2.5 px-3 text-xs">${escapeLogHtml(progressText)}</td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${statusCodeClass}">${escapeLogHtml(l.status_code??'-')}</span></td><td class="py-2.5 px-3 align-top"><div class="w-[17rem] max-w-[17rem] text-xs leading-5 whitespace-pre-wrap break-words line-clamp-2 ${formatLogOutcomeClass(l)}" title="${escapeLogHtml(outcomeText)}">${escapeLogHtml(outcomePreview)}</div></td><td class="py-2.5 px-3">${Number(l.duration||0).toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">&#35814;&#24773;</button></td></tr>`}).join('');if(window.activeLogDetailId&&window.logListMeta[window.activeLogDetailId])await showLogDetail(window.activeLogDetailId,{silent:true})}catch(e){console.error('load logs failed:',e);if(!silent)showToast('\u52a0\u8f7d\u65e5\u5fd7\u5931\u8d25: '+e.message,'error')}},
refreshLogs=async()=>{await loadLogs()},
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){window.logDetailCache=Object.create(null);window.activeLogDetailId=null;resetLogMediaCache();showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
showLogDetail=async(logId,opts={})=>{const silent=!!opts.silent;const modal=$('logDetailModal');const content=$('logDetailContent');window.logDetailCache=window.logDetailCache||Object.create(null);window.logListMeta=window.logListMeta||Object.create(null);window.logDetailRequestSeq=(window.logDetailRequestSeq||0)+1;const requestSeq=window.logDetailRequestSeq;window.activeLogDetailId=logId;modal.classList.remove('hidden');content.innerHTML='<div class="rounded-md border border-border p-4 bg-muted/30 text-sm text-muted-foreground">\u65e5\u5fd7\u8be6\u60c5\u52a0\u8f7d\u4e2d...</div>';try{const meta=window.logListMeta[logId];let log=window.logDetailCache[logId];if(!log||!meta||log.updated_at!==meta.updated_at){const r=await apiRequest(`/api/logs/${logId}`);if(!r)return;if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;if(r.status===404){content.innerHTML='<div class="rounded-md border border-red-200 p-4 bg-red-50 text-sm text-red-700">\u65e5\u5fd7\u4e0d\u5b58\u5728\u6216\u5df2\u88ab\u5220\u9664</div>';return}log=await r.json();window.logDetailCache[logId]=log}if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;renderLogDetail(log)}catch(e){if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;console.error('\u52a0\u8f7d\u65e5\u5fd7\u8be6\u60c5\u5931\u8d25:',e);content.innerHTML=`<div class="rounded-md border border-red-200 p-4 bg-red-50 text-sm text-red-700">\u52a0\u8f7d\u65e5\u5fd7\u8be6\u60c5\u5931\u8d25: ${escapeLogHtml(e.message||'\u672a\u77e5\u9519\u8bef')}</div>`;if(!silent)showToast('\u52a0\u8f7d\u65e5\u5fd7\u8be6\u60c5\u5931\u8d25: '+e.message,'error')}},
closeLogDetailModal=()=>{window.activeLogDetailId=null;resetLogMediaCache();$('logDetailModal').classList.add('hidden')},
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'){stopLogsAutoRefresh();loadAdminConfig();loadProxyConfig();loadCacheConfig();loadGenerationTimeout();loadCallLogicConfig();loadCaptchaConfig();loadPluginConfig();loadATAutoRefreshConfig()}else if(t==='logs'){startLogsAutoRefresh();loadLogs()}else{stopLogsAutoRefresh()}};
function escapeLogHtml(text){
if(text===null||text===undefined) return '';
return String(text).replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function parseLogJson(raw){
if(!raw) return null;
try{return JSON.parse(raw)}catch(_){return null}
}
function truncateLogText(text,limit=240){
const value=String(text??'').trim().replace(/\s+/g,' ');
if(!value) return '';
return value.length<=limit?value:`${value.slice(0,limit-3)}...`;
}
function extractLogErrorSummary(log,responseBodyObj){
const direct=log&&typeof log.error_summary==='string'?log.error_summary.trim():'';
if(direct) return truncateLogText(direct);
const payload=responseBodyObj===undefined?parseLogJson(log&&log.response_body):responseBodyObj;
const visit=value=>{
if(value===null||value===undefined) return '';
if(typeof value==='string') return truncateLogText(value);
if(Array.isArray(value)){for(const item of value){const nested=visit(item);if(nested)return nested}return''}
if(typeof value==='object'){
for(const key of ['error_summary','error_message','detail','message']){
if(typeof value[key]==='string'&&value[key].trim()) return truncateLogText(value[key]);
}
const errorValue=value.error;
if(typeof errorValue==='string'&&errorValue.trim()) return truncateLogText(errorValue);
if(errorValue&&typeof errorValue==='object'){
for(const key of ['message','detail','reason','code']){
if(typeof errorValue[key]==='string'&&errorValue[key].trim()) return truncateLogText(errorValue[key]);
}
}
for(const key of ['response','data','performance']){
const nested=visit(value[key]);
if(nested) return nested;
}
}
return '';
};
const summary=visit(payload);
if(summary) return summary;
if(log&&log.response_body&&!payload) return truncateLogText(log.response_body);
return '';
}
function extractLogPrimaryUrl(responseBodyObj){
if(!responseBodyObj||typeof responseBodyObj!=='object') return null;
return responseBodyObj.url||((responseBodyObj.data&&responseBodyObj.data[0]&&responseBodyObj.data[0].url)?responseBodyObj.data[0].url:null)||(responseBodyObj.generated_assets&&responseBodyObj.generated_assets.final_video_url)||((responseBodyObj.generated_assets&&responseBodyObj.generated_assets.upscaled_image&&responseBodyObj.generated_assets.upscaled_image.local_url)?responseBodyObj.generated_assets.upscaled_image.local_url:null)||((responseBodyObj.generated_assets&&responseBodyObj.generated_assets.upscaled_image&&responseBodyObj.generated_assets.upscaled_image.url)?responseBodyObj.generated_assets.upscaled_image.url:null)||(responseBodyObj.generated_assets&&responseBodyObj.generated_assets.final_image_url)||null;
}
function extractLogSuccessSummary(log,responseBodyObj){
if((log&&Number(log.status_code))!==200) return '';
if(!responseBodyObj||typeof responseBodyObj!=='object') return '生成成功,已返回结果';
const assets=responseBodyObj.generated_assets;
if(assets&&typeof assets==='object'&&assets.upscaled_image&&typeof assets.upscaled_image==='object'){
return `生成成功,已返回${assets.upscaled_image.resolution||'高清'}结果`;
}
const directUrl=extractLogPrimaryUrl(responseBodyObj);
if(directUrl){
if(isVideoUrl(directUrl)) return '生成成功,已返回视频结果';
if(isImageUrl(directUrl)) return '生成成功,已返回图片结果';
return '生成成功,已返回结果地址';
}
return responseBodyObj.status==='success'?'生成成功':'生成成功,已返回结果';
}
function formatLogPayload(raw){
const parsed=parseLogJson(raw);
if(parsed){
return JSON.stringify(parsed,(_,value)=>{
if(typeof value!=='string') return value;
if(value.length<=4096) return value;
if(/^data:(image|video)\//i.test(value)) return `[数据URL已省略长度=${value.length}]`;
const sample=value.slice(0,256);
if(/^[A-Za-z0-9+/=\r\n]+$/.test(sample)) return `[大体积Base64已省略长度=${value.length}]`;
return `${value.slice(0,800)}... [已截断,长度=${value.length}]`;
},2);
}
if(!raw) return '无';
const text=String(raw);
if(text.length<=6000) return text;
return `${text.slice(0,1200)}... [已截断,长度=${text.length}]`;
}
function resetLogMediaCache(){
window.logMediaUrlCache=Object.create(null);
window.logMediaSeq=0;
}
function getLogDetailToggleState(logId,key){
window.logDetailToggleState=window.logDetailToggleState||Object.create(null);
const state=window.logDetailToggleState[String(logId)]||Object.create(null);
return !!state[key];
}
function setLogDetailToggleState(logId,key,isOpen){
window.logDetailToggleState=window.logDetailToggleState||Object.create(null);
const stateKey=String(logId);
window.logDetailToggleState[stateKey]=window.logDetailToggleState[stateKey]||Object.create(null);
window.logDetailToggleState[stateKey][key]=!!isOpen;
}
function syncLogDetailViewState(){
const container=$('logDetailContent');
if(!container) return;
const toggleLogId=container.dataset.logId||'';
if(!toggleLogId) return;
container.querySelectorAll('details[data-detail-key]').forEach(el=>{
const key=el.dataset.detailKey||'';
el.open=getLogDetailToggleState(toggleLogId,key);
});
container.querySelectorAll('[data-preview-key]').forEach(el=>{
const key=el.dataset.previewKey||'';
const shouldOpen=getLogDetailToggleState(toggleLogId,key);
if(!shouldOpen) return;
const button=container.querySelector(`button[data-preview-key="${key}"]`);
if(button) loadLogMediaPreview(button);
});
}
function cacheLogMediaUrl(url){
if(!url) return '';
window.logMediaUrlCache=window.logMediaUrlCache||Object.create(null);
window.logMediaSeq=(window.logMediaSeq||0)+1;
const key=`log-media-${window.logMediaSeq}`;
window.logMediaUrlCache[key]=url;
return key;
}
function normalizeLogMediaUrl(url){
if(!url) return '';
const text=String(url).trim();
if(!text||/^data:/i.test(text)) return text;
try{
const parsed=new URL(text,window.location.origin);
if(parsed.pathname.startsWith('/tmp/')){
return `${window.location.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
return parsed.toString();
}catch(_){
return text;
}
}
function renderLogLink(label,url){
if(!url) return '';
const normalizedUrl=normalizeLogMediaUrl(url);
if(/^data:/i.test(String(normalizedUrl))){
return `<p class="text-xs"><span class="font-medium">${label}:</span> <span class="text-muted-foreground">data URL长度 ${String(normalizedUrl).length}</span></p>`;
}
const safeUrl=escapeLogHtml(normalizedUrl);
return `<p class="text-xs"><span class="font-medium">${label}:</span> <a href="${safeUrl}" target="_blank" class="text-blue-600 hover:underline break-all">${safeUrl}</a></p>`;
}
function isVideoUrl(url){
if(!url) return false;
const text=String(url).toLowerCase();
if(text.startsWith('data:video/')) return true;
return /(\.mp4|\.webm|\.mov|\.m3u8)(\?|$)/.test(text)||text.includes('/video/');
}
function isImageUrl(url){
if(!url) return false;
const text=String(url).toLowerCase();
if(text.startsWith('data:image/')) return true;
return /(\.png|\.jpg|\.jpeg|\.webp|\.gif|\.avif|\.bmp)(\?|$)/.test(text)||text.includes('/image/');
}
function renderMediaPreview(label,url,withUrl=true){
if(!url) return '';
const previewUrl=normalizeLogMediaUrl(url);
const mediaType=isVideoUrl(previewUrl)?'video':(isImageUrl(previewUrl)?'image':'');
const mediaKey=mediaType?cacheLogMediaUrl(previewUrl):'';
const previewTrigger=mediaType?`<button onclick="loadLogMediaPreview(this)" data-media-key="${mediaKey}" data-label="${escapeLogHtml(label)}" data-media-type="${mediaType}" class="inline-flex items-center justify-center rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent">点击加载预览</button><div class="space-y-2"></div>`:'';
return `<div class="space-y-2"><p class="text-xs font-medium">${escapeLogHtml(label)}</p>${withUrl?renderLogLink('URL',previewUrl):''}${previewTrigger}</div>`;
}
function handleLogMediaPreviewError(node){
if(!node) return;
const mediaUrl=node.dataset.mediaUrl||'';
const mediaLabel=node.dataset.mediaLabel||'媒体';
node.outerHTML=`<div class="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700 space-y-2"><p>${escapeLogHtml(mediaLabel)}预览加载失败,请直接打开链接查看。</p>${renderLogLink('URL',mediaUrl)}</div>`;
}
function loadLogMediaPreview(button){
if(!button) return;
const container=button.nextElementSibling;
const mediaKey=button.dataset.mediaKey||'';
const url=(window.logMediaUrlCache&&window.logMediaUrlCache[mediaKey])||'';
const label=button.dataset.label||'';
const mediaType=button.dataset.mediaType||'';
if(!container||!url||!mediaType) return;
if(mediaType==='video'){
container.innerHTML=`<video src="${escapeLogHtml(url)}" controls preload="metadata" data-media-url="${escapeLogHtml(url)}" data-media-label="${escapeLogHtml(label)}" onerror="handleLogMediaPreviewError(this)" class="w-full max-h-80 rounded-md border border-border bg-black"></video>`;
}else{
container.innerHTML=`<img src="${escapeLogHtml(url)}" alt="${escapeLogHtml(label)}" loading="lazy" decoding="async" data-media-url="${escapeLogHtml(url)}" data-media-label="${escapeLogHtml(label)}" onerror="handleLogMediaPreviewError(this)" class="max-h-80 rounded-md border border-border object-contain bg-background">`;
}
button.remove();
}
function renderLogDetail(log){
if(!log){showToast('日志不存在','error');return}
resetLogMediaCache();
const responseBodyObj=parseLogJson(log.response_body);
const requestPayloadText=formatLogPayload(log.request_body);
const responsePayloadText=formatLogPayload(log.response_body);
const toggleLogId=String(log.id??'');
const errorSummary=extractLogErrorSummary(log,responseBodyObj);
const successSummary=extractLogSuccessSummary(log,responseBodyObj);
let detailHtml='';
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">请求数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(requestPayloadText)}</pre></div>`;
if(log.status_code===200){
if(successSummary){
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-green-700">结果摘要</h4><div class="rounded-md border border-green-200 p-3 bg-green-50"><p class="text-sm text-green-700">${escapeLogHtml(successSummary)}</p></div></div>`;
}
if(responseBodyObj){
const directUrl=extractLogPrimaryUrl(responseBodyObj);
if(directUrl){
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30 space-y-3">${renderMediaPreview('主结果',directUrl)}</div></div>`;
}
const assets=responseBodyObj.generated_assets;
if(assets&&typeof assets==='object'){
let assetsHtml='';
if(assets.upscaled_image&&typeof assets.upscaled_image==='object'){
const up=assets.upscaled_image;
const upResolution=up.resolution||'放大';
const upPreviewUrl=up.local_url||up.url||null;
assetsHtml+=`<p class="text-xs"><span class="font-medium">放大分辨率:</span> ${escapeLogHtml(upResolution)}</p>`;
if(upPreviewUrl){
assetsHtml+=renderMediaPreview(`${upResolution}结果`,upPreviewUrl,!/^data:/i.test(String(upPreviewUrl||'')));
}
if(up.base64){
const preview=String(up.base64).length>600?`${String(up.base64).slice(0,600)}...`:String(up.base64);
assetsHtml+=`<p class="text-xs"><span class="font-medium">Base64长度:</span> ${String(up.base64).length}</p><details class="rounded border border-border p-2 bg-background"><summary class="cursor-pointer text-xs text-muted-foreground">查看Base64预览</summary><pre class="mt-2 text-xs overflow-x-auto">${escapeLogHtml(preview)}</pre></details>`;
}
}else{
const extraMediaUrl=assets.final_video_url||assets.final_image_url||null;
if(extraMediaUrl&&extraMediaUrl!==directUrl){
assetsHtml+=renderMediaPreview('额外结果',extraMediaUrl,false);
}
}
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">2K/4K 资产信息</h4><div class="rounded-md border border-border p-3 bg-muted/30 space-y-2">${assetsHtml||'<p class="text-xs text-muted-foreground">无资产详情</p>'}</div></div>`;
}
detailHtml+=`<details class="space-y-2"><summary class="cursor-pointer text-sm font-medium">完整响应(大字段已截断)</summary><pre class="mt-2 rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></details>`;
}else{
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></div>`;
}
}else{
if(errorSummary){
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700 break-all">${escapeLogHtml(errorSummary)}</p></div></div>`;
}
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误响应</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></div>`;
}
detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${escapeLogHtml(log.operation||'-')}</div><div><span class="text-muted-foreground">状态:</span> ${escapeLogHtml(formatLogStatus(log))}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===200?'bg-green-50 text-green-700':(log.status_code===102?'bg-amber-50 text-amber-700':'bg-red-50 text-red-700')}">${escapeLogHtml(log.status_code??'-')}</span></div><div><span class="text-muted-foreground">耗时:</span> ${Number(log.duration||0).toFixed(2)}秒</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div><div><span class="text-muted-foreground">Token:</span> ${escapeLogHtml(log.token_email||log.token_username||'未知')}</div><div><span class="text-muted-foreground">日志ID:</span> ${escapeLogHtml(log.id??'-')}</div><div><span class="text-muted-foreground">进度:</span> ${escapeLogHtml(formatLogProgress(log))}</div></div></div>`;
const detailContent=$('logDetailContent');detailContent.dataset.logId=String(log.id??'');detailContent.innerHTML=detailHtml;syncLogDetailViewState();
}
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
</script>
</body>
</html>