mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-04 02:04:42 +08:00
1148 lines
134 KiB
HTML
1148 lines
134 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN" class="h-full">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>管理控制台 - Sora2API</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style>
|
||
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.animate-slide-up{animation:slide-up .3s ease-out}
|
||
.tab-btn{transition:all .2s ease}
|
||
</style>
|
||
<script>
|
||
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
||
</script>
|
||
</head>
|
||
<body class="h-full bg-background text-foreground antialiased">
|
||
<!-- 导航栏 -->
|
||
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur">
|
||
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
||
<div class="mr-4 flex items-baseline gap-3">
|
||
<span class="font-bold text-xl">Sora2API</span>
|
||
</div>
|
||
<div class="flex flex-1 items-center justify-end gap-3">
|
||
<a href="https://github.com/TheSmallHanCat/sora2api" target="_blank" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5" title="GitHub 仓库">
|
||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||
</svg>
|
||
</a>
|
||
<button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1">
|
||
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||
<polyline points="16 17 21 12 16 7"/>
|
||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||
</svg>
|
||
退出
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="mx-auto max-w-7xl px-6 py-6">
|
||
<!-- 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>
|
||
<button onclick="switchTab('generate')" id="tabGenerate" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">生成面板</button>
|
||
</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">
|
||
<div class="flex items-center gap-2">
|
||
<h3 class="text-lg font-semibold">Token 列表</h3>
|
||
<!-- 状态筛选按钮 -->
|
||
<div class="relative">
|
||
<button id="statusFilterBtn" onclick="toggleStatusFilterDropdown()" class="inline-flex items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 h-8 text-sm transition-colors hover:bg-accent hover:border-primary" title="筛选状态">
|
||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||
</svg>
|
||
<span id="statusFilterLabel">全部</span>
|
||
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
</button>
|
||
<!-- 下拉菜单 -->
|
||
<div id="statusFilterDropdown" class="hidden absolute left-0 mt-2 w-32 rounded-md border border-border bg-background shadow-lg z-50">
|
||
<div class="py-1">
|
||
<button onclick="selectStatusFilter('')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<!-- 自动刷新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距离过期<24h时自动使用ST或RT刷新AT
|
||
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
|
||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||
</svg>
|
||
</button>
|
||
<!-- 批量操作下拉菜单 -->
|
||
<style>
|
||
.batch-dropdown-container {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.batch-dropdown-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
background: #6366f1;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.batch-dropdown-btn:hover {
|
||
background: #4f46e5;
|
||
}
|
||
.batch-dropdown-arrow {
|
||
margin-left: 4px;
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.batch-dropdown-container:hover .batch-dropdown-arrow {
|
||
transform: rotate(180deg);
|
||
}
|
||
.batch-dropdown-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
min-width: 160px;
|
||
background: white;
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
z-index: 50;
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translateY(-8px);
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
overflow: hidden;
|
||
}
|
||
.batch-dropdown-container:hover .batch-dropdown-menu {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0);
|
||
}
|
||
.batch-dropdown-item {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
background: none;
|
||
color: #374151;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.batch-dropdown-item:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
.batch-dropdown-item.purple:hover { background: #faf5ff; color: #9333ea; }
|
||
.batch-dropdown-item.teal:hover { background: #f0fdfa; color: #0d9488; }
|
||
.batch-dropdown-item.orange:hover { background: #fff7ed; color: #ea580c; }
|
||
.batch-dropdown-item.red:hover { background: #fef2f2; color: #dc2626; }
|
||
.batch-dropdown-item.blue:hover { background: #eff6ff; color: #2563eb; }
|
||
.batch-dropdown-item + .batch-dropdown-item {
|
||
border-top: 1px solid #f3f4f6;
|
||
}
|
||
.batch-dropdown-icon {
|
||
width: 14px;
|
||
height: 14px;
|
||
margin-right: 8px;
|
||
}
|
||
</style>
|
||
<div class="batch-dropdown-container">
|
||
<button class="batch-dropdown-btn" 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">
|
||
<path d="M9 11l3 3L22 4"/>
|
||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||
</svg>
|
||
<span class="text-sm font-medium">批量操作</span>
|
||
<svg class="batch-dropdown-arrow h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
</button>
|
||
<div class="batch-dropdown-menu">
|
||
<button onclick="batchTestUpdate()" class="batch-dropdown-item purple" title="一键测试更新所有Token">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||
</svg>
|
||
<span>测试更新</span>
|
||
</button>
|
||
<button onclick="batchEnableAll()" class="batch-dropdown-item teal" title="一键启用所有禁用的Token">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 11l3 3L22 4"/>
|
||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||
</svg>
|
||
<span>批量启用</span>
|
||
</button>
|
||
<button onclick="batchDisableSelected()" class="batch-dropdown-item orange" title="批量禁用选中的Token">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||
</svg>
|
||
<span>批量禁用</span>
|
||
</button>
|
||
<button onclick="batchDeleteDisabled()" class="batch-dropdown-item red" title="一键清理所有禁用的Token">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="3 6 5 6 21 6"/>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
</svg>
|
||
<span>清理禁用</span>
|
||
</button>
|
||
<button onclick="batchDeleteSelected()" class="batch-dropdown-item red" title="删除所有选中的Token(不管是否禁用)">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="3 6 5 6 21 6"/>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||
</svg>
|
||
<span>删除选中</span>
|
||
</button>
|
||
<button onclick="openBatchProxyModal()" class="batch-dropdown-item blue" title="批量修改选中Token的代理">
|
||
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 1v6m0 6v6m-5.2-9.8l4.2 4.2m4.2 0l4.2-4.2m-12.6 0l4.2 4.2m4.2 0l4.2 4.2"/>
|
||
</svg>
|
||
<span>修改代理</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<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-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="border-b border-border">
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">
|
||
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" class="h-4 w-4 rounded border-gray-300">
|
||
</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">邮箱</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Client ID</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">账户类型</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">可用次数</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">备注</th>
|
||
<th class="h-10 px-3 text-right align-middle font-medium text-muted-foreground">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tokenTableBody" class="divide-y divide-border">
|
||
<!-- 动态填充 -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<!-- 分页控件 -->
|
||
<div id="paginationContainer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统配置面板 -->
|
||
<div id="panelSettings" class="hidden">
|
||
<div class="grid gap-6 lg:grid-cols-2">
|
||
<!-- 安全配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">安全配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">管理员用户名</label>
|
||
<input id="cfgAdminUsername" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||
<p class="text-xs text-muted-foreground mt-1">管理员用户名</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">旧密码</label>
|
||
<input id="cfgOldPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入旧密码">
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">新密码</label>
|
||
<input id="cfgNewPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新密码">
|
||
</div>
|
||
<button onclick="updateAdminPassword()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">修改密码</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API 密钥配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">当前 API Key</label>
|
||
<input id="cfgCurrentAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" readonly disabled>
|
||
<p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">新 API Key</label>
|
||
<input id="cfgNewAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新的 API Key">
|
||
<p class="text-xs text-muted-foreground mt-1">用于客户端调用 API 的密钥</p>
|
||
</div>
|
||
<button onclick="updateAPIKey()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">更新 API Key</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 代理配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">代理配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input">
|
||
<span class="text-sm font-medium">启用代理</span>
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">代理地址</label>
|
||
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">测试域名</label>
|
||
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
|
||
<p class="text-xs text-muted-foreground mt-1">用于测试代理连接的目标域名</p>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
<button onclick="testProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-muted hover:bg-muted/80 h-9 px-4 w-full">测试代理</button>
|
||
</div>
|
||
<div id="proxyStatusMessage" class="text-xs hidden"></div>
|
||
<div class="text-xs text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md border border-amber-200 dark:border-amber-900">
|
||
<strong>⚠️ 提示:</strong>代理测试成功仅表示代理服务器可以正常连接到目标域名,并不能保证代理 IP 所在地区可以使用 Sora 服务。请确保您的代理 IP 位于支持 Sora 的地区。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- POW代理配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">POW代理配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgPowProxyEnabled" class="h-4 w-4 rounded border-input">
|
||
<span class="text-sm font-medium">启用POW代理</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">POW代理地址</label>
|
||
<input id="cfgPowProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
|
||
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
|
||
</div>
|
||
<button onclick="savePowProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错误处理配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">错误封禁阈值</label>
|
||
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
|
||
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
|
||
</div>
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgTaskRetryEnabled" class="h-4 w-4 rounded border-input">
|
||
<span class="text-sm font-medium">启用任务失败重试</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-1">生成任务失败时自动重试,直到成功或达到最大重试次数</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">最大重试次数</label>
|
||
<input id="cfgTaskMaxRetries" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3" min="1" max="10">
|
||
<p class="text-xs text-muted-foreground mt-1">任务失败后最多重试的次数(1-10次)</p>
|
||
</div>
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgAutoDisableOn401" class="h-4 w-4 rounded border-input">
|
||
<span class="text-sm font-medium">遇到401错误自动禁用Token</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-1">当Token返回401错误时,自动禁用该Token并使用其他Token重试</p>
|
||
</div>
|
||
<button onclick="saveAdminConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 缓存配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">缓存配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
|
||
<span class="text-sm font-medium">启用缓存</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
|
||
</div>
|
||
|
||
<!-- 缓存配置选项 -->
|
||
<div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
|
||
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="-1" max="86400">
|
||
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时),-1 表示永不自动删除</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
|
||
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
|
||
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p>
|
||
</div>
|
||
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
|
||
<p class="text-xs text-muted-foreground">
|
||
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成超时配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">生成超时配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">图片生成超时时间(秒)</label>
|
||
<input id="cfgImageTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="300" min="60" max="3600">
|
||
<p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label>
|
||
<input id="cfgVideoTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1500" min="60" max="7200">
|
||
<p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p>
|
||
</div>
|
||
<button onclick="saveGenerationTimeout()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无水印模式配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">无水印模式配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgWatermarkFreeEnabled" class="h-4 w-4 rounded border-input" onchange="toggleWatermarkFreeOptions()">
|
||
<span class="text-sm font-medium">开启无水印模式</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-2">开启后生成的视频将会被发布到sora平台并且提取返回无水印的视频,在缓存到本地后会自动删除发布的视频</p>
|
||
</div>
|
||
|
||
<!-- 解析方式选择 -->
|
||
<div id="watermarkFreeOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
|
||
<div>
|
||
<label class="text-sm font-medium">解析方式</label>
|
||
<select id="cfgParseMethod" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground" onchange="toggleCustomParseOptions()">
|
||
<option value="third_party">第三方解析</option>
|
||
<option value="custom">自定义解析接口</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 回退开关 -->
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgFallbackOnFailure" class="h-4 w-4 rounded border-input" checked>
|
||
<span class="text-sm font-medium">去水印失败后自动回退</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-2">开启后,去水印失败时自动回退到带水印视频;关闭后,去水印失败将标记任务为失败状态</p>
|
||
</div>
|
||
|
||
<!-- 自定义解析配置 -->
|
||
<div id="customParseOptions" style="display: none;" class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium">解析服务器地址</label>
|
||
<input type="text" id="cfgCustomParseUrl" placeholder="请输入解析服务器地址 (例如: http://example.com)" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
||
<p class="text-xs text-muted-foreground mt-1"><a href="https://github.com/tibbar213/sora-downloader" target="_blank" class="text-blue-600 hover:text-blue-800 underline">部署自定义解析服务器</a></p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium">访问密钥</label>
|
||
<input type="password" id="cfgCustomParseToken" placeholder="请输入访问密钥" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button onclick="saveWatermarkFreeConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 调用逻辑配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">账号调用逻辑</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium block">调用模式</label>
|
||
<select id="cfgCallLogicMode" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
||
<option value="default">随机轮询</option>
|
||
<option value="polling">逐个轮询</option>
|
||
</select>
|
||
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
|
||
</div>
|
||
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 调试配置 -->
|
||
<div class="rounded-lg border border-border bg-background p-6">
|
||
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()">
|
||
<span class="text-sm font-medium">启用调试模式</span>
|
||
</label>
|
||
<p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件,立即生效</p>
|
||
</div>
|
||
<div>
|
||
<button onclick="downloadDebugLogs()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 text-sm font-medium transition-colors">
|
||
<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>
|
||
下载日志文件
|
||
</button>
|
||
<p class="text-xs text-muted-foreground mt-2">下载完整的调试日志文件 (logs.txt)</p>
|
||
</div>
|
||
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
|
||
<p class="text-xs text-yellow-800 dark:text-yellow-200">
|
||
⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 请求日志面板 -->
|
||
<div id="panelLogs" class="hidden">
|
||
<div class="rounded-lg border border-border bg-background">
|
||
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
||
<h3 class="text-lg font-semibold">请求日志</h3>
|
||
<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 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 w-32">操作</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-40">Token邮箱</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-20">状态码</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">进度</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-24">耗时(秒)</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-44">时间</th>
|
||
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="logsTableBody" class="divide-y divide-border">
|
||
<!-- 动态填充 -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成面板(单层嵌入,铺满主内容宽度) -->
|
||
<div id="panelGenerate" class="hidden" style="background: #ffffff;">
|
||
<iframe id="generateFrame" src="/static/generate.html" class="w-full" style="border: 0; background: #ffffff; display:block; width:100%; height: 800px;" loading="lazy"></iframe>
|
||
</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">
|
||
<!-- Access Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label>
|
||
<textarea id="addTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea>
|
||
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p>
|
||
</div>
|
||
|
||
<!-- Session Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<div class="flex gap-2">
|
||
<textarea id="addTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea>
|
||
<button onclick="convertST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto">
|
||
ST→AT
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p>
|
||
</div>
|
||
|
||
<!-- Refresh Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<div class="flex gap-2">
|
||
<textarea id="addTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea>
|
||
<button onclick="convertRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto">
|
||
RT→AT
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p>
|
||
<p id="addRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p>
|
||
</div>
|
||
|
||
<!-- Client ID -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Client ID <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="addTokenClientId" 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">用于 RT 刷新,留空使用默认 Client ID</p>
|
||
</div>
|
||
|
||
<!-- Proxy URL -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="addTokenProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||
<p class="text-xs text-muted-foreground">支持 http 和 socks5 代理,留空使用系统设置的代理</p>
|
||
</div>
|
||
|
||
<!-- Remark -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
||
</div>
|
||
|
||
<!-- 功能开关 -->
|
||
<div class="space-y-3 pt-2 border-t border-border">
|
||
<label class="text-sm font-medium">功能开关</label>
|
||
<div class="space-y-2">
|
||
<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="3" 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">
|
||
|
||
<!-- Access Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label>
|
||
<textarea id="editTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea>
|
||
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p>
|
||
</div>
|
||
|
||
<!-- Session Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<div class="flex gap-2">
|
||
<textarea id="editTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea>
|
||
<button onclick="convertEditST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto">
|
||
ST→AT
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p>
|
||
</div>
|
||
|
||
<!-- Refresh Token -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<div class="flex gap-2">
|
||
<textarea id="editTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea>
|
||
<button onclick="convertEditRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto">
|
||
RT→AT
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p>
|
||
<p id="editRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p>
|
||
</div>
|
||
|
||
<!-- Client ID -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">Client ID <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="editTokenClientId" 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">用于 RT 刷新,留空使用默认 Client ID</p>
|
||
</div>
|
||
|
||
<!-- Proxy URL -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">代理 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="editTokenProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||
<p class="text-xs text-muted-foreground">支持 http 和 socks5 代理,留空使用系统设置的代理</p>
|
||
</div>
|
||
|
||
<!-- Remark -->
|
||
<div class="space-y-2">
|
||
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
||
<input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
||
</div>
|
||
|
||
<!-- 功能开关 -->
|
||
<div class="space-y-3 pt-2 border-t border-border">
|
||
<label class="text-sm font-medium">功能开关</label>
|
||
<div class="space-y-2">
|
||
<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 id="jsonFileSection">
|
||
<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>
|
||
|
||
<!-- 纯RT导入输入框区域 -->
|
||
<div id="pureRtSection" class="hidden space-y-2.5">
|
||
<div>
|
||
<label class="text-sm font-medium mb-1.5 block">Refresh Token 列表</label>
|
||
<textarea id="pureRtInput" rows="3" placeholder="每行一个 RT" class="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm resize-none"></textarea>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2.5">
|
||
<div>
|
||
<label class="text-sm font-medium mb-1.5 block">Client ID(可选)</label>
|
||
<input type="text" id="pureRtClientId" placeholder="留空使用默认值" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-1.5 block">代理地址(可选)</label>
|
||
<input type="text" id="pureRtProxy" placeholder="http://127.0.0.1:7890" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2.5">
|
||
<div>
|
||
<label class="text-sm font-medium mb-1.5 block">图片并发</label>
|
||
<input type="number" id="pureRtImageConcurrency" value="1" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium mb-1.5 block">视频并发</label>
|
||
<input type="number" id="pureRtVideoConcurrency" value="3" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">💡 提示:自动刷新并批量导入,并发 -1 表示不限制</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-sm font-medium mb-2 block">选择导入模式</label>
|
||
<select id="importMode" onchange="updateImportModeHint()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||
<option value="at">优先使用AT导入(推荐)</option>
|
||
<option value="offline">离线导入(不更新账号状态)</option>
|
||
<option value="st">优先使用ST导入</option>
|
||
<option value="rt">优先使用RT导入</option>
|
||
<option value="pure_rt">纯RT导入</option>
|
||
</select>
|
||
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态(订阅信息、Sora2次数等)</p>
|
||
</div>
|
||
<div id="importModeHelpSection" class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
|
||
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">📋 导入模式说明</p>
|
||
<div class="space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
|
||
<div class="flex items-start gap-2">
|
||
<span class="font-medium min-w-[100px]">AT导入:</span>
|
||
<span>完整更新所有账号信息(推荐)</span>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<span class="font-medium min-w-[100px]">离线导入:</span>
|
||
<span>直接插入数据库,快速导入不调用API,账号信息需要单独获取 -</span>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<span class="font-medium min-w-[100px]">ST导入:</span>
|
||
<span>适用于只有ST没有AT,自动转换为AT</span>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<span class="font-medium min-w-[100px]">RT导入:</span>
|
||
<span>适用于只有RT没有AT,自动转换为AT</span>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<span class="font-medium min-w-[100px]">纯RT导入:</span>
|
||
<span>手动输入RT列表(一行一个),自动转换并批量导入</span>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||
💡 提示:离线导入后可使用"测试"按钮更新账号信息,功能不稳定有bug问猫猫
|
||
</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>
|
||
|
||
<div id="importProgressModal" 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-2xl 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="closeImportProgressModal()" 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 flex-1">
|
||
<div id="importProgressSummary" class="mb-4 p-3 rounded-md bg-muted"></div>
|
||
<div id="importProgressList" class="space-y-2"></div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
||
<button onclick="closeImportProgressModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量修改代理模态框 -->
|
||
<div id="batchProxyModal" 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">批量修改代理</h3>
|
||
<button onclick="closeBatchProxyModal()" 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">代理地址</label>
|
||
<input type="text" id="batchProxyUrl" 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="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-900 dark:text-blue-100">
|
||
<span class="font-semibold">提示:</span>将为 <span id="batchProxyCount" class="font-semibold">0</span> 个选中的Token修改代理地址
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
||
<button onclick="closeBatchProxyModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
||
<button id="batchProxyBtn" onclick="submitBatchProxy()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
||
<svg id="batchProxyBtnSpinner" class="hidden animate-spin -ml-1 mr-2 h-4 w-4" 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>
|
||
<span id="batchProxyBtnText">确认修改</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set(),currentStatusFilter='';
|
||
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();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
||
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');statusSet.add(statusText)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
||
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
||
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
||
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
||
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
||
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');return statusText===currentStatusFilter})},
|
||
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
||
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
||
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
||
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
||
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||
changePage=(page)=>{currentPage=page;renderTokens()},
|
||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
||
renderPagination=()=>{const filteredTokens=getFilteredTokens();const totalPages=Math.ceil(filteredTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+filteredTokens.length+' 条'+(currentStatusFilter?' (筛选后)':' (总数: '+allTokens.length+')')+'</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
|
||
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='1';$('addTokenVideoConcurrency').value='3';$('addRTRefreshHint').classList.add('hidden')},
|
||
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenClientId').value=token.client_id||'';$('editTokenProxyUrl').value=token.proxy_url||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
|
||
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenProxyUrl').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')},
|
||
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),proxyUrl=$('editTokenProxyUrl').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
||
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||
convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const clientId=$('addTokenClientId').value.trim();const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt,client_id:clientId||null})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||
convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const clientId=$('editTokenClientId').value.trim();const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt,client_id:clientId||null})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),clientId=$('addTokenClientId').value.trim(),proxyUrl=$('addTokenProxyUrl').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
|
||
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
||
toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
|
||
toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
|
||
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}},
|
||
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
|
||
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
|
||
openImportProgressModal=()=>{$('importProgressModal').classList.remove('hidden')},
|
||
closeImportProgressModal=()=>{$('importProgressModal').classList.add('hidden')},
|
||
openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('hidden')},
|
||
closeBatchProxyModal=()=>{$('batchProxyModal').classList.add('hidden');$('batchProxyUrl').value=''},
|
||
submitBatchProxy=async()=>{const proxyUrl=$('batchProxyUrl').value.trim();const btn=$('batchProxyBtn'),btnText=$('batchProxyBtnText'),btnSpinner=$('batchProxyBtnSpinner');btn.disabled=true;btnText.textContent='修改中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/batch/update-proxy',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds),proxy_url:proxyUrl})});if(!r){btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeBatchProxyModal();selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('修改失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden')}},
|
||
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const isFailed=r.status==='failed';const isAdded=r.status==='added';const statusColor=isFailed?'text-red-600':(isAdded?'text-green-600':'text-blue-600');const statusText=isAdded?'新增':(r.status==='updated'?'更新':'失败');const bgColor=isFailed?'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800':(isAdded?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800');const errorMsg=r.message&&isFailed?r.message:(r.error||'');return`<div class="p-3 rounded-md border ${bgColor}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${errorMsg?`<div class="text-xs text-red-600 dark:text-red-400 mt-1 whitespace-pre-wrap">${errorMsg}</div>`:''}</div>`}).join('');openImportProgressModal()},
|
||
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,client_id:t.client_id||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
|
||
batchTestUpdate=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要测试的Token','info');return}if(!confirm(`⚠️ 警告\n\n此操作将请求上游获取选中的 ${selectedTokenIds.size} 个Token的状态信息,可能需要较长时间。\n\n确定要继续吗?`)){return}showToast('正在测试更新选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
|
||
batchEnableAll=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要启用的Token','info');return}if(!confirm(`确定要启用选中的 ${selectedTokenIds.size} 个Token吗?\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
|
||
batchDeleteDisabled=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}const disabledCount=Array.from(selectedTokenIds).filter(id=>{const token=allTokens.find(t=>t.id===id);return token&&!token.is_active}).length;if(disabledCount===0){showToast('选中的Token中没有禁用的Token','info');return}if(!confirm(`⚠️ 警告\n\n选中了 ${selectedTokenIds.size} 个Token,其中 ${disabledCount} 个是禁用的。\n\n即将删除这 ${disabledCount} 个禁用Token。\n\n此操作不可恢复!确定要继续吗?`)){return}showToast('正在删除选中的禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||
batchDeleteSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除选中的 ${selectedTokenIds.size} 个Token(包括正常和禁用的)。\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${selectedTokenIds.size} 个Token吗?\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${selectedTokenIds.size} 个Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在删除选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||
toggleSelectAll=()=>{const checkbox=$('selectAllCheckbox');const checkboxes=document.querySelectorAll('.token-checkbox');if(checkbox.checked){checkboxes.forEach(cb=>{cb.checked=true;const tokenId=parseInt(cb.getAttribute('data-token-id'));selectedTokenIds.add(tokenId)})}else{checkboxes.forEach(cb=>{cb.checked=false});selectedTokenIds.clear()}},
|
||
toggleTokenSelection=(tokenId,checked)=>{if(checked){selectedTokenIds.add(tokenId)}else{selectedTokenIds.delete(tokenId)}const allCheckboxes=document.querySelectorAll('.token-checkbox');const allChecked=Array.from(allCheckboxes).every(cb=>cb.checked);$('selectAllCheckbox').checked=allChecked},
|
||
batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗?`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量禁用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量禁用失败: '+e.message,'error')}},
|
||
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),jsonSection=$('jsonFileSection'),pureRtSection=$('pureRtSection'),helpSection=$('importModeHelpSection'),hints={at:'使用AT更新账号状态(订阅信息、Sora2次数等)',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT,然后更新账号状态',rt:'自动将RT转换为AT(并刷新RT),然后更新账号状态',pure_rt:'手动输入RT列表,自动刷新并批量导入'};hint.textContent=hints[mode]||'';if(mode==='pure_rt'){jsonSection.classList.add('hidden');pureRtSection.classList.remove('hidden');helpSection.classList.add('hidden')}else{jsonSection.classList.remove('hidden');pureRtSection.classList.add('hidden');helpSection.classList.remove('hidden')}},
|
||
submitImportTokens=async()=>{const mode=$('importMode').value;const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');if(mode==='pure_rt'){const rtInput=$('pureRtInput').value.trim();if(!rtInput){showToast('请输入 Refresh Token','error');return}const clientId=$('pureRtClientId').value.trim()||'app_LlGpXReQgckcGGUo2JrYvtJK';const proxy=$('pureRtProxy').value.trim()||null;const imageConcurrency=parseInt($('pureRtImageConcurrency').value)||1;const videoConcurrency=parseInt($('pureRtVideoConcurrency').value)||3;const rtList=rtInput.split('\n').map(rt=>rt.trim()).filter(rt=>rt.length>0);if(rtList.length===0){showToast('请输入至少一个 Refresh Token','error');return}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import/pure-rt',{method:'POST',body:JSON.stringify({refresh_tokens:rtList,client_id:clientId,proxy_url:proxy,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}return}const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
|
||
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
|
||
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
|
||
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}},
|
||
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
|
||
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
|
||
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
|
||
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`✓ ${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`✗ ${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
|
||
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
|
||
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null,fallback_on_failure:fallbackOnFailure})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
|
||
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
|
||
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
|
||
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
|
||
loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}},
|
||
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout!==-1&&(timeout<60||timeout>86400))return showToast('缓存超时时间必须在 60-86400 秒之间,或 -1 表示永不删除','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
||
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
||
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
|
||
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
|
||
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const progress=l.progress||0;const statusText=isProcessing?(progress>0?'生成中':'排队中'):l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}">${statusText}</span></td><td class="py-2.5 px-3">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
|
||
refreshLogs=async()=>{await loadLogs()},
|
||
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?((log.progress||0)>0?'生成中':'排队中'):log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
|
||
closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
|
||
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
|
||
cancelTask=async(taskId)=>{if(!confirm('确定要终止这个任务吗?'))return;try{const r=await apiRequest(`/api/tasks/${taskId}/cancel`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast('任务已终止','success');await loadLogs()}else{showToast('终止失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('终止失败: '+e.message,'error')}},
|
||
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'},
|
||
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
|
||
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
|
||
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||
loadPowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowProxyEnabled').checked=d.config.pow_proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.pow_proxy_url||''}else{console.error('POW代理配置数据格式错误:',d)}}catch(e){console.error('加载POW代理配置失败:',e)}},
|
||
savePowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config',{method:'POST',body:JSON.stringify({pow_proxy_enabled:$('cfgPowProxyEnabled').checked,pow_proxy_url:$('cfgPowProxyUrl').value.trim()})});if(!r)return;const d=await r.json();if(d.success){showToast('POW代理配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadPowProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
|
||
// 自适应生成面板 iframe 高度
|
||
window.addEventListener('message', (event) => {
|
||
const data = event.data || {};
|
||
if (data.type === 'sora-generate-height' && typeof data.height === 'number') {
|
||
const iframe = document.getElementById('generateFrame');
|
||
if (iframe) {
|
||
const nextH = Math.max(560, Math.ceil(data.height) + 8);
|
||
iframe.style.height = `${nextH}px`;
|
||
}
|
||
}
|
||
});
|
||
// 点击页面其他地方关闭筛选下拉菜单
|
||
document.addEventListener('click', (e) => {
|
||
const dropdown = $('statusFilterDropdown');
|
||
const btn = $('statusFilterBtn');
|
||
if (!dropdown || !btn) return;
|
||
if (!dropdown.classList.contains('hidden') && !btn.contains(e.target) && !dropdown.contains(e.target)) {
|
||
dropdown.classList.add('hidden');
|
||
}
|
||
});
|
||
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
||
</script>
|
||
</body>
|
||
</html>
|