| | <!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>管理控制台 - Grok2API</title> |
| | <link rel="icon" type="image/png" href="/static/favicon.png"> |
| | <script src="https://cdn.tailwindcss.com"></script> |
| | <script src="https://cdn.jsdelivr.net/npm/chart.js"></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 |
| | } |
| | |
| | .hover-card { |
| | position: relative; |
| | display: inline-block |
| | } |
| | |
| | .hover-card-trigger { |
| | cursor: pointer |
| | } |
| | |
| | .hover-card-content { |
| | position: absolute; |
| | left: 50%; |
| | transform: translateX(-50%); |
| | background: hsl(0 0% 3.9%); |
| | color: hsl(0 0% 98%); |
| | padding: 8px 12px; |
| | border-radius: 6px; |
| | font-size: 12px; |
| | font-weight: 500; |
| | white-space: nowrap; |
| | z-index: 9999; |
| | pointer-events: none; |
| | opacity: 0; |
| | visibility: hidden; |
| | transition: opacity .2s ease, transform .2s ease; |
| | box-shadow: 0 4px 12px rgba(0, 0, 0, .25); |
| | border: 1px solid hsl(0 0% 14.9%) |
| | } |
| | |
| | .hover-card:hover .hover-card-content { |
| | opacity: 1; |
| | visibility: visible |
| | } |
| | |
| | .hover-card-content.top { |
| | bottom: 100%; |
| | transform: translateX(-50%) translateY(-8px) |
| | } |
| | |
| | .hover-card-content.bottom { |
| | top: 100%; |
| | transform: translateX(-50%) translateY(8px) |
| | } |
| | |
| | .hover-card-content::after { |
| | content: ''; |
| | position: absolute; |
| | left: 50%; |
| | transform: translateX(-50%); |
| | border: 6px solid transparent; |
| | z-index: 1000 |
| | } |
| | |
| | .hover-card-content.top::after { |
| | top: 100%; |
| | border-top-color: hsl(0 0% 3.9%) |
| | } |
| | |
| | .hover-card-content.bottom::after { |
| | bottom: 100%; |
| | border-bottom-color: hsl(0 0% 3.9%) |
| | } |
| | |
| | .hover-card-trigger:hover+.hover-card-content { |
| | opacity: 1; |
| | visibility: visible |
| | } |
| | |
| | .btn-icon { |
| | display: inline-flex; |
| | align-items: center; |
| | justify-content: center; |
| | border-radius: 0.375rem; |
| | transition: color .2s, background-color .2s; |
| | height: 2rem; |
| | width: 2rem |
| | } |
| | |
| | .btn-icon:focus-visible { |
| | outline: 2px solid transparent; |
| | outline-offset: 2px; |
| | box-shadow: 0 0 0 1px hsl(0 0% 3.9%) |
| | } |
| | |
| | .btn-icon:hover { |
| | background-color: hsl(0 0% 96.1%); |
| | color: hsl(0 0% 9%) |
| | } |
| | |
| | .sticky-right { |
| | position: sticky; |
| | right: 0; |
| | background-color: hsl(0 0% 100%); |
| | z-index: 10; |
| | border-left: 1px solid hsl(0 0% 89%) |
| | } |
| | |
| | .cfg-label { |
| | font-size: 0.875rem; |
| | font-weight: 500; |
| | color: hsl(0 0% 45.1%); |
| | margin-bottom: 0.5rem; |
| | display: flex; |
| | align-items: center; |
| | gap: 0.25rem |
| | } |
| | |
| | .cfg-input { |
| | display: flex; |
| | height: 2.25rem; |
| | width: 100%; |
| | border-radius: 0.375rem; |
| | border: 1px solid hsl(0 0% 89%); |
| | background-color: hsl(0 0% 100%); |
| | padding: 0.5rem 0.75rem; |
| | font-size: 0.875rem |
| | } |
| | |
| | .help-icon { |
| | display: inline-flex; |
| | align-items: center; |
| | justify-content: center; |
| | width: 0.875rem; |
| | height: 0.875rem; |
| | border-radius: 9999px; |
| | border: 1px solid hsl(0 0% 45.1%); |
| | color: hsl(0 0% 45.1%); |
| | cursor: help; |
| | font-size: 10px; |
| | line-height: 1 |
| | } |
| | |
| | [title] { |
| | position: relative |
| | } |
| | |
| | [title]:hover::after { |
| | content: attr(title); |
| | position: absolute; |
| | bottom: 100%; |
| | left: 50%; |
| | transform: translateX(-50%) translateY(-8px); |
| | background: hsl(0 0% 11%); |
| | color: hsl(0 0% 98%); |
| | padding: 8px 12px; |
| | border-radius: 6px; |
| | font-size: 12px; |
| | font-weight: 500; |
| | line-height: 1.4; |
| | white-space: pre-line; |
| | max-width: 350px; |
| | width: max-content; |
| | word-wrap: break-word; |
| | z-index: 1000; |
| | pointer-events: none; |
| | box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1); |
| | opacity: 0; |
| | visibility: hidden; |
| | transition: all 0.2s ease; |
| | animation: tooltipFadeIn 0.2s ease forwards |
| | } |
| | |
| | [title]:hover::before { |
| | content: ''; |
| | position: absolute; |
| | bottom: 100%; |
| | left: 50%; |
| | transform: translateX(-50%) translateY(-4px); |
| | border: 6px solid transparent; |
| | border-top-color: hsl(0 0% 11%); |
| | z-index: 1000; |
| | opacity: 0; |
| | visibility: hidden; |
| | transition: opacity 0.2s ease, visibility 0.2s ease |
| | } |
| | |
| | [title]:hover::after, |
| | [title]:hover::before { |
| | opacity: 1; |
| | visibility: visible |
| | } |
| | |
| | @keyframes tooltipFadeIn { |
| | from { |
| | opacity: 0; |
| | transform: translateX(-50%) translateY(-4px) |
| | } |
| | |
| | to { |
| | opacity: 1; |
| | transform: translateX(-50%) translateY(-8px) |
| | } |
| | } |
| | </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%)" }, accent: { DEFAULT: "hsl(0 0% 96.1%)", foreground: "hsl(0 0% 9%)" }, 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 supports-[backdrop-filter]:bg-background/60"> |
| | <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">Grok2API</span> |
| | <span class="text-xs text-gray-400">by @Chenyme & @Tomiya233</span> |
| | </div> |
| | <div class="flex flex-1 items-center justify-end"> |
| | <div class="flex items-center gap-2"> |
| | <div class="hover-card"> |
| | <span id="storageMode" |
| | class="hover-card-trigger inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground border"> |
| | <svg class="h-3 w-3 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" |
| | stroke="currentColor" stroke-width="2"> |
| | <ellipse cx="12" cy="5" rx="9" ry="3" /> |
| | <path d="m21 12c0 1.66-4 3-9 3s-9-1.34-9-3" /> |
| | <path d="m3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" /> |
| | </svg> |
| | <span id="storageModeText">FILE</span> |
| | </span> |
| | <div id="storageModeTooltip" class="hover-card-content top"> |
| | 加载中... |
| | </div> |
| | </div> |
| | <nav class="flex items-center gap-1"> |
| | <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> |
| | </nav> |
| | </div> |
| | </div> |
| | </header> |
| |
|
| | <main class="mx-auto max-w-7xl px-6 py-6"> |
| | |
| | <div class="border-b border-border mb-6"> |
| | <nav class="flex space-x-8"> |
| | <button onclick="switchTab('tokens')" id="tabTokens" |
| | class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button> |
| | <button onclick="switchTab('statistics')" id="tabStatistics" |
| | class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">请求统计</button> |
| | <button onclick="switchTab('keys')" id="tabKeys" |
| | class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Key 管理</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('cache')" id="tabCache" |
| | class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">缓存预览</button> |
| | <button onclick="switchTab('settings')" id="tabSettings" |
| | class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Setting 配置</button> |
| | </nav> |
| | </div> |
| |
|
| | |
| | <div id="panelTokens"> |
| | |
| | <div class="grid gap-4 grid-cols-2 md:grid-cols-4 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">Token 未使用</p> |
| | <h3 class="text-xl font-bold text-gray-500" id="statUnused">-</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-orange-600" id="statLimited">-</h3> |
| | <p class="text-xs text-muted-foreground mt-2"> |
| | 冷却 <span id="statCooldown">-</span> · 耗尽 <span id="statExhausted">-</span> |
| | </p> |
| | </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-destructive" id="statExpired">-</h3> |
| | </div> |
| |
|
| | <div class="rounded-lg border border-border bg-background p-4"> |
| | <p class="text-sm font-medium text-muted-foreground mb-2">Chat 总剩余</p> |
| | <h3 class="text-xl font-bold" id="statChatRemaining">-</h3> |
| | </div> |
| |
|
| | <div class="rounded-lg border border-border bg-background p-4"> |
| | <p class="text-sm font-medium text-muted-foreground mb-2">Image 总剩余</p> |
| | <h3 class="text-xl font-bold text-blue-600" id="statImageRemaining">-</h3> |
| | </div> |
| |
|
| | <div class="rounded-lg border border-border bg-background p-4"> |
| | <p class="text-sm font-medium text-muted-foreground mb-2">Video 总剩余</p> |
| | <h3 class="text-xl font-bold text-purple-600" id="statVideoRemaining">无法统计</h3> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background"> |
| | |
| | <div class="flex items-center justify-between gap-4 p-4 border-b border-border"> |
| | <div class="flex items-center gap-3 flex-1"> |
| | <div class="flex items-center gap-2"> |
| | <select id="filterType" onchange="filterTokens()" |
| | class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]"> |
| | <option value="all">全部类型</option> |
| | <option value="sso">SSO</option> |
| | <option value="ssoSuper">SuperSSO</option> |
| | </select> |
| | <select id="filterStatus" onchange="filterTokens()" |
| | class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]"> |
| | <option value="all">全部状态</option> |
| | <option value="未使用">未使用</option> |
| | <option value="冷却中">冷却中</option> |
| | <option value="额度耗尽">额度耗尽</option> |
| | <option value="失效">失效</option> |
| | <option value="正常">正常</option> |
| | </select> |
| | <select id="filterTag" onchange="filterTokens()" |
| | class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]"> |
| | <option value="all">全部标签</option> |
| | </select> |
| | </div> |
| | </div> |
| |
|
| | <div class="flex items-center gap-2"> |
| | <button onclick="refreshTokens()" class="btn-icon" title="刷新列表"> |
| | <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" |
| | stroke="currentColor" stroke-width="2"> |
| | <polyline points="23 4 23 10 17 10" /> |
| | <polyline points="1 20 1 14 7 14" /> |
| | <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /> |
| | </svg> |
| | </button> |
| | <button onclick="refreshAllTokens()" class="btn-icon" title="刷新所有Token剩余次数"> |
| | <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"> |
| | <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /> |
| | <path d="M3 3v5h5" /> |
| | <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /> |
| | <path d="M16 21h5v-5" /> |
| | </svg> |
| | </button> |
| | <div id="batchActions" class="hidden items-center gap-2"> |
| | <button onclick="exportSelected()" class="btn-icon" 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"> |
| | <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> |
| | <button onclick="batchDelete()" class="btn-icon hover:bg-destructive/10 hover:text-destructive" |
| | 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="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="10" y1="11" x2="10" y2="17" /> |
| | <line x1="14" y1="11" x2="14" y2="17" /> |
| | </svg> |
| | </button> |
| | </div> |
| | <button onclick="openAddModal()" |
| | class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 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"> |
| | <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 table-fixed"> |
| | <thead> |
| | <tr class="border-b border-border"> |
| | <th class="h-10 px-3 text-left align-middle font-medium w-12"> |
| | <input type="checkbox" id="selectAll" onchange="toggleSelectAll()" |
| | class="h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring"> |
| | </th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-72">Token</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">类型</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">状态</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">普通</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">高级</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">限流信息</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-48">最近失败</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">标签</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">备注</th> |
| | <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">创建时间</th> |
| | <th |
| | class="h-10 px-3 text-center align-middle text-sm font-medium text-muted-foreground w-28 sticky-right"> |
| | 操作</th> |
| | </tr> |
| | </thead> |
| | <tbody id="tokenTableBody" class="divide-y divide-border"> |
| | |
| | </tbody> |
| | </table> |
| | </div> |
| |
|
| | <div id="emptyState" class="hidden flex flex-col items-center justify-center py-12"> |
| | <svg class="h-10 w-10 text-muted-foreground/50 mb-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" |
| | fill="none" stroke="currentColor" stroke-width="2"> |
| | <rect x="3" y="3" width="18" height="18" rx="2" /> |
| | <path d="M9 9h6v6H9z" /> |
| | </svg> |
| | <p class="text-sm text-muted-foreground">暂无数据</p> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="panelKeys" class="hidden"> |
| | |
| | <div class="flex items-center justify-between gap-4 mb-6"> |
| | <h2 class="text-xl font-bold">Key Management</h2> |
| | <div class="flex items-center gap-2"> |
| | <button onclick="loadKeys()" class="btn-icon" 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"> |
| | <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /> |
| | <path d="M3 3v5h5" /> |
| | <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /> |
| | <path d="M16 21h5v-5" /> |
| | </svg> |
| | </button> |
| | <button onclick="openBatchAddModal()" |
| | class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring border border-input bg-background hover:bg-accent hover:text-accent-foreground 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"> |
| | <rect x="3" y="3" width="18" height="18" rx="2" /> |
| | <line x1="12" y1="8" x2="12" y2="16" /> |
| | <line x1="8" y1="12" x2="16" y2="12" /> |
| | </svg> |
| | <span class="text-sm font-medium">批量创建</span> |
| | </button> |
| | <button onclick="openAddKeyModal()" |
| | class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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">新增 Key</span> |
| | </button> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="batchActionBar" |
| | class="hidden mb-4 p-3 rounded-lg bg-primary/5 border border-primary/20 flex items-center justify-between"> |
| | <div class="flex items-center gap-3"> |
| | <span class="text-sm font-medium text-primary"><span id="selectedCount">0</span> 个项目被选中</span> |
| | </div> |
| | <div class="flex items-center gap-2"> |
| | <button onclick="batchUpdateStatus(true)" |
| | class="text-xs px-2 py-1 rounded bg-background border border-border hover:bg-accent transition-colors">批量启用</button> |
| | <button onclick="batchUpdateStatus(false)" |
| | class="text-xs px-2 py-1 rounded bg-background border border-border hover:bg-accent transition-colors">批量禁用</button> |
| | <button onclick="batchDeleteKeys()" |
| | class="text-xs px-2 py-1 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors">批量删除</button> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background"> |
| | <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-4 text-left align-middle font-medium w-12"> |
| | <input type="checkbox" id="selectAllKeys" onchange="toggleSelectAllKeys(this)" |
| | class="rounded border-border text-primary focus:ring-primary h-4 w-4"> |
| | </th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-48">备注</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-64">Key</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-32">创建时间</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-24">状态</th> |
| | <th class="h-10 px-4 text-right align-middle font-medium w-48">操作</th> |
| | </tr> |
| | </thead> |
| | <tbody id="keyTableBody" class="divide-y divide-border"> |
| | |
| | </tbody> |
| | </table> |
| | </div> |
| | <div id="keyEmptyState" class="hidden flex flex-col items-center justify-center py-12"> |
| | <p class="text-sm text-muted-foreground">暂无 API Key</p> |
| | </div> |
| | </div> |
| |
|
| | <div id="globalKeyAlert" class="mt-4 p-4 rounded-lg bg-orange-50 border border-orange-200 hidden"> |
| | <div class="flex items-start gap-3"> |
| | <svg class="h-5 w-5 text-orange-600 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" |
| | stroke="currentColor" stroke-width="2"> |
| | <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> |
| | <line x1="12" y1="9" x2="12" y2="13" /> |
| | <line x1="12" y1="17" x2="12.01" y2="17" /> |
| | </svg> |
| | <div> |
| | <h4 class="text-sm font-bold text-orange-800">存在全局 Key</h4> |
| | <p class="text-sm text-orange-700 mt-1"> |
| | 系统检测到 `setting.toml` 中配置了旧的全局 `api_key`。 |
| | 它被视为 "默认管理员" Key,此处无法修改或删除。如果希望完全使用多 Key 管理,请清空配置文件中的 api_key。 |
| | </p> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="panelLogs" class="hidden"> |
| | <div class="flex items-center justify-between mb-6"> |
| | <h2 class="text-xl font-bold">日志审计</h2> |
| | <div class="flex items-center gap-2"> |
| | <button onclick="loadLogs()" class="btn-icon" 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"> |
| | <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /> |
| | <path d="M3 3v5h5" /> |
| | <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /> |
| | <path d="M16 21h5v-5" /> |
| | </svg> |
| | </button> |
| | <button onclick="clearLogs()" |
| | class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-destructive text-destructive-foreground hover:bg-destructive/90 h-8 px-3"> |
| | <span class="text-sm font-medium">清空日志</span> |
| | </button> |
| | </div> |
| | </div> |
| |
|
| | <div class="rounded-lg border border-border bg-background"> |
| | <div class="relative w-full overflow-auto" style="max-height: 800px;"> |
| | <table class="w-full text-sm"> |
| | <thead> |
| | <tr class="border-b border-border sticky top-0 bg-background z-10"> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-40">时间</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-32">Key 名称</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-40">模型</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-32">IP</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-24">耗时</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium w-24">状态</th> |
| | <th class="h-10 px-4 text-left align-middle font-medium">详情</th> |
| | </tr> |
| | </thead> |
| | <tbody id="logTableBody" class="divide-y divide-border"> |
| | |
| | </tbody> |
| | </table> |
| | </div> |
| | <div id="logEmptyState" class="hidden flex flex-col items-center justify-center py-12"> |
| | <p class="text-sm text-muted-foreground">暂无日志</p> |
| | </div> |
| | |
| | <div id="logPagination" class="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/20"> |
| | <div class="text-xs text-muted-foreground"> |
| | 共 <span id="logTotalCount">0</span> 条日志 |
| | </div> |
| | <div class="flex items-center gap-2"> |
| | <button onclick="changeLogPage(-1)" id="logPrevBtn" |
| | class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 w-8 border border-input bg-background hover:bg-accent disabled:opacity-50 transition-colors"> |
| | <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="15 18 9 12 15 6" /> |
| | </svg> |
| | </button> |
| | <span class="text-xs font-medium px-2">第 <span id="logCurrentPage">1</span> / <span id="logMaxPage">1</span> |
| | 页</span> |
| | <button onclick="changeLogPage(1)" id="logNextBtn" |
| | class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 w-8 border border-input bg-background hover:bg-accent disabled:opacity-50 transition-colors"> |
| | <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="9 18 15 12 9 6" /> |
| | </svg> |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="panelCache" class="hidden"> |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <div class="flex items-center justify-between mb-4"> |
| | <h3 class="text-sm font-semibold">缓存预览</h3> |
| | <div class="flex items-center gap-2"> |
| | <div class="inline-flex rounded-md border border-input overflow-hidden"> |
| | <button id="cacheTabImage" onclick="switchCachePreviewType('image')" |
| | class="px-3 h-8 text-xs font-medium bg-primary text-primary-foreground">图片</button> |
| | <button id="cacheTabVideo" onclick="switchCachePreviewType('video')" |
| | class="px-3 h-8 text-xs font-medium bg-muted text-muted-foreground">视频</button> |
| | </div> |
| | <button onclick="refreshCachePreview()" |
| | class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-3 transition-colors">刷新</button> |
| | </div> |
| | </div> |
| | <div id="cachePreviewMeta" class="text-xs text-muted-foreground mb-3">图片缓存:0 个</div> |
| | <div id="cachePreviewGrid" class="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"> |
| | <div class="col-span-full text-sm text-muted-foreground text-center py-6">暂无缓存文件</div> |
| | </div> |
| | <div class="mt-3 flex items-center justify-center"> |
| | <button id="cacheLoadMoreBtn" onclick="loadMoreCachePreview()" |
| | class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-4 transition-colors">加载更多</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="panelSettings" class="hidden"> |
| | |
| | <div class="mb-8"> |
| | <div class="flex items-center justify-between mb-6"> |
| | <h2 class="text-xl font-bold">全局配置</h2> |
| | <button onclick="saveGlobalSettings()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">保存配置</button> |
| | </div> |
| | <div class="grid gap-4 lg:grid-cols-3"> |
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">系统设置</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="cfg-label">登陆账户<span class="help-icon" title="登录管理后台的用户名">?</span></label> |
| | <input id="cfgAdminUser" class="cfg-input" placeholder="admin"> |
| | </div> |
| | <div> |
| | <label class="cfg-label">登陆密码<span class="help-icon" title="登录管理后台的密码,留空表示不修改当前密码">?</span></label> |
| | <input id="cfgAdminPass" type="password" class="cfg-input" placeholder="留空则不修改"> |
| | </div> |
| | <div> |
| | <label class="cfg-label">日志级别<span class="help-icon" |
| | title="日志详细程度。DEBUG:最详细 | INFO:一般信息 | WARNING:警告 | ERROR:仅错误">?</span></label> |
| | <select id="cfgLogLevel" class="cfg-input"> |
| | <option>DEBUG</option> |
| | <option>INFO</option> |
| | <option>WARNING</option> |
| | <option>ERROR</option> |
| | </select> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">媒体设置</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="cfg-label"> |
| | 图片模式 |
| | <span class="help-icon" title="返回图片的方式。URL:图片链接,支持图片缓存 | Base64:base64编码,不支持缓存">?</span> |
| | </label> |
| | <select id="cfgImageMode" class="cfg-input"> |
| | <option value="url">URL链接</option> |
| | <option value="base64">Base64</option> |
| | </select> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 服务网址 |
| | <span class="help-icon" title="服务器的公网访问地址,用于构建图片URL链接(仅在图片模式为URL时需要)">?</span> |
| | </label> |
| | <input id="cfgBaseUrl" class="cfg-input" placeholder="http://localhost:8000"> |
| | </div> |
| | <div class="grid grid-cols-2 gap-3"> |
| | <div> |
| | <label class="cfg-label"> |
| | 图片缓存 (MB) |
| | <span class="help-icon" title="图片缓存的最大容量(MB),超过后会自动清理旧缓存">?</span> |
| | </label> |
| | <input id="cfgImageCacheMaxSize" type="number" class="cfg-input" placeholder="500"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 视频缓存 (MB) |
| | <span class="help-icon" title="视频缓存的最大容量(MB),超过后会自动清理旧缓存">?</span> |
| | </label> |
| | <input id="cfgVideoCacheMaxSize" type="number" class="cfg-input" placeholder="1000"> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">缓存管理</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="text-sm font-medium text-muted-foreground mb-2 block">图片缓存</label> |
| | <div class="flex gap-2"> |
| | <input id="imageCacheSize" readonly |
| | class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" |
| | placeholder="0 MB"> |
| | <button onclick="clearImageCache()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors"> |
| | <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"> |
| | <path |
| | d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" /> |
| | </svg> |
| | </button> |
| | </div> |
| | </div> |
| | <div> |
| | <label class="text-sm font-medium text-muted-foreground mb-2 block">视频缓存</label> |
| | <div class="flex gap-2"> |
| | <input id="videoCacheSize" readonly |
| | class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" |
| | placeholder="0 MB"> |
| | <button onclick="clearVideoCache()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors"> |
| | <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"> |
| | <path |
| | d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" /> |
| | </svg> |
| | </button> |
| | </div> |
| | </div> |
| | <div> |
| | <label class="text-sm font-medium text-muted-foreground mb-2 block">所有缓存</label> |
| | <div class="flex gap-2"> |
| | <input id="totalCacheSize" readonly |
| | class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm font-medium" |
| | placeholder="0 MB"> |
| | <button onclick="clearCache()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors"> |
| | <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"> |
| | <path |
| | d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" /> |
| | </svg> |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | </div> |
| | </div> |
| |
|
| | |
| | <div> |
| | <div class="flex items-center justify-between mb-6"> |
| | <h2 class="text-xl font-bold">Grok 配置</h2> |
| | <button onclick="saveGrokSettings()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">保存配置</button> |
| | </div> |
| | <div class="grid gap-4 lg:grid-cols-3"> |
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">基础设置</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="cfg-label"> |
| | API Key |
| | <span class="help-icon" title="接口调用的身份验证密钥,用于保护API访问安全">?</span> |
| | </label> |
| | <input id="cfgApiKey" class="cfg-input" placeholder=""> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | X Statsig ID |
| | <span class="help-icon" title="Statsig统计ID,用于功能实验和统计分析">?</span> |
| | </label> |
| | <div class="flex items-center gap-3"> |
| | <input id="cfgStatsigId" |
| | class="flex h-9 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm" |
| | placeholder=""> |
| | <label class="inline-flex items-center gap-2 cursor-pointer" title="开启后每次请求自动生成新的 x-statsig-id"> |
| | <span class="text-xs text-muted-foreground whitespace-nowrap">动态</span> |
| | <div class="relative"> |
| | <input type="checkbox" id="cfgDynamicStatsig" class="sr-only peer"> |
| | <div |
| | class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring 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-4 after:w-4 after:transition-all peer-checked:bg-primary"> |
| | </div> |
| | </div> |
| | </label> |
| | </div> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 过滤标签 |
| | <span class="help-icon" title="需要过滤的响应标签,多个标签用逗号分隔。如:xaiartifact,xai:tool_usage_card">?</span> |
| | </label> |
| | <input id="cfgFilteredTags" class="cfg-input" placeholder="xaiartifact,xai:tool_usage_card"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 显示思考 |
| | <span class="help-icon" title="开启后会显示模型的思考过程(<think>标签内容);关闭后仅返回最终结果">?</span> |
| | </label> |
| | <select id="cfgShowThinking" class="cfg-input"> |
| | <option value="true">开启</option> |
| | <option value="false">关闭</option> |
| | </select> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 临时会话 |
| | <span class="help-icon" title="开启后每次对话都创建新会话,不保留历史;关闭后可以继续之前的对话">?</span> |
| | </label> |
| | <select id="cfgTemporary" class="cfg-input"> |
| | <option value="false">关闭</option> |
| | <option value="true">开启</option> |
| | </select> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">代理设置</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="cfg-label"> |
| | CF Clearance |
| | <span class="help-icon" |
| | title="Cloudflare验证cookie的值部分,用于绕过Cloudflare人机验证。只需输入cf_clearance=后面的值。">?</span> |
| | </label> |
| | <input id="cfgCfClearance" class="cfg-input" placeholder=""> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | Proxy Url (服务代理) |
| | <span class="help-icon" |
| | title="API请求和上传使用的代理。支持 http、https、socks5。格式:socks5://user:pass@host:port">?</span> |
| | </label> |
| | <input id="cfgProxyUrl" class="cfg-input" placeholder="socks5://username:password@127.0.0.1:7890"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | Proxy Pool URL (代理池API) |
| | <span class="help-icon" |
| | title="代理池API地址,返回单个代理URL。留空则使用上方的固定代理。 返回格式:纯文本,单行代理地址 示例返回:socks5h://1.2.3.4:1080 支持协议:http://, https://, socks5://, socks5h:// API示例:http://your-api.com/get">?</span> |
| | </label> |
| | <input id="cfgProxyPoolUrl" class="cfg-input" placeholder="http://your-proxy-api.com/get"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | Proxy Pool Interval (刷新间隔秒) |
| | <span class="help-icon" title="代理池自动刷新间隔,单位秒。建议300-600秒(5-10分钟)">?</span> |
| | </label> |
| | <input id="cfgProxyPoolInterval" type="number" class="cfg-input" placeholder="300"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | Cache Proxy Url (缓存代理) |
| | <span class="help-icon" title="图片/视频缓存下载专用代理,不设置则使用服务代理。Grok的图片/视频获取接口对IP风控要求不高,可使用便宜的大流量节点">?</span> |
| | </label> |
| | <input id="cfgCacheProxyUrl" class="cfg-input" placeholder="socks5://username:password@127.0.0.1:7890"> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">超时设置</h3> |
| | <div class="space-y-4"> |
| | <div> |
| | <label class="cfg-label"> |
| | 首次响应超时 (秒) |
| | <span class="help-icon" title="等待API首次返回数据的最大时间(秒)。超时后会报错,建议30-60秒">?</span> |
| | </label> |
| | <input id="cfgStreamFirstResponseTimeout" type="number" class="cfg-input" placeholder="30"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 流式间隔超时 (秒) |
| | <span class="help-icon" title="两次数据块之间的最大间隔时间(秒)。如果超过此时间没有收到新数据则断开,建议60-180秒">?</span> |
| | </label> |
| | <input id="cfgStreamChunkTimeout" type="number" class="cfg-input" placeholder="120"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 生成总过程超时 (秒) |
| | <span class="help-icon" title="整个对话生成的最大总时长(秒)。适用于超长对话,建议300-900秒">?</span> |
| | </label> |
| | <input id="cfgStreamTotalTimeout" type="number" class="cfg-input" placeholder="600"> |
| | </div> |
| | <div> |
| | <label class="cfg-label"> |
| | 可重试状态码 |
| | <span class="help-icon" |
| | title="遇到这些HTTP状态码时会自动换token重试。多个状态码用逗号分隔。默认:401,429 常见状态码:401(未授权), 429(速率限制), 500(服务器错误), 502(网关错误), 503(服务不可用)">?</span> |
| | </label> |
| | <input id="cfgRetryStatusCodes" class="cfg-input" placeholder="401,429"> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="mt-8 rounded border border-blue-300 bg-blue-50 px-4 py-3"> |
| | <div class="text-xs text-gray-800 leading-relaxed"> |
| | <div class="text-base font-medium text-gray-900 mb-2.5">部分说明</div> |
| | <div class="space-y-1.5"> |
| | <div><span class="font-medium">X Statsig ID:</span>反机器人验证参数。开启"动态 Statsig ID"后会自动生成,固定值将被忽略;关闭则使用上方设置的固定值 |
| | </div> |
| | <div><span class="font-medium">动态 Statsig:</span>开启后每次请求自动生成新的 x-statsig-id,增强请求多样性,推荐开启</div> |
| | <div><span class="font-medium">服务网址:</span>图片/视频链接返回时需要拼接您的服务网址(如 |
| | https://yourdomain.com),若您不使用视频功能且图片使用Base64模式则可留空</div> |
| | <div><span class="font-medium">代理设置:</span>服务代理用于访问Grok |
| | API和上传图片;缓存代理专门用于下载图片和视频缓存。若仅设置服务代理,缓存将使用相同的代理;若都设置,则分别使用不同的代理</div> |
| | <div><span class="font-medium">请求 403:</span>通常是被 CF 拦截了,可采用以下办法之一:1. 更换服务器IP | 2. 配置代理IP | 3.在服务器中访问 |
| | grok.com 通过 CF 验证后 F12 获取 cf_clearance</div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="panelStatistics" class="hidden"> |
| | |
| | <div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6"> |
| | <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" id="reqStatTotal">-</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-green-600" id="reqStatSuccess">-</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="reqStatFailed">-</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="reqStatRate">-</h3> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="grid gap-6 lg:grid-cols-2 mb-6"> |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">24小时请求趋势</h3> |
| | <canvas id="hourlyChart" height="200"></canvas> |
| | </div> |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">7天请求统计</h3> |
| | <canvas id="dailyChart" height="200"></canvas> |
| | </div> |
| | <div class="rounded-lg border border-border bg-background p-6"> |
| | <h3 class="text-sm font-semibold mb-4">模型调用分布</h3> |
| | <canvas id="modelsChart" height="200"></canvas> |
| | </div> |
| | </div> |
| | </div> |
| | </main> |
| |
|
| | |
| | <div id="editTagsModal" 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="closeEditModal()" class="text-muted-foreground hover:text-foreground transition-colors"> |
| | <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 class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">Token</label> |
| | <input id="editTokenInput" readonly |
| | class="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-xs font-mono" placeholder=""> |
| | </div> |
| | <div class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">标签 <span |
| | class="text-muted-foreground">(多个标签用逗号分隔)</span></label> |
| | <input id="editTagsInput" placeholder="例如: 生产环境, 测试用" |
| | class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring" /> |
| | <div id="suggestedTags" class="flex flex-wrap gap-2 mt-2"></div> |
| | </div> |
| | <div class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">备注</label> |
| | <textarea id="editNoteInput" rows="3" placeholder="添加备注信息..." |
| | class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none"></textarea> |
| | </div> |
| | </div> |
| | <div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30"> |
| | <button onclick="closeEditModal()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5"> |
| | 取消 |
| | </button> |
| | <button onclick="submitEditInfo()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"> |
| | 保存 |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="addModal" 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 max-h-[90vh] overflow-y-auto 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="closeAddModal()" class="text-muted-foreground hover:text-foreground transition-colors"> |
| | <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 class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">Token 类型</label> |
| | <select id="addTokenType" |
| | class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"> |
| | <option value="sso">SSO</option> |
| | <option value="ssoSuper">SuperSSO</option> |
| | </select> |
| | </div> |
| | <div class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">Token 列表 <span |
| | class="text-muted-foreground">(每行一个)</span></label> |
| | <textarea id="addTokenList" rows="12" placeholder="请输入 Token,每行一个" |
| | class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring font-mono resize-none"></textarea> |
| | </div> |
| | </div> |
| | <div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30"> |
| | <button onclick="closeAddModal()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5"> |
| | 取消 |
| | </button> |
| | <button onclick="submitAddTokens()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"> |
| | 添加 |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="modalAddKey" 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">新增 API Key</h3> |
| | <button onclick="closeAddKeyModal()" class="text-muted-foreground hover:text-foreground transition-colors"> |
| | <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 class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">备注名称</label> |
| | <input type="text" id="newKeyName" placeholder="例如: 某某应用使用" |
| | class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"> |
| | </div> |
| | </div> |
| | <div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30"> |
| | <button onclick="closeAddKeyModal()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input bg-background hover:bg-accent h-9 px-5">取消</button> |
| | <button onclick="doAddKey()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">确认</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="modalBatchAddKey" 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">批量创建 API Key</h3> |
| | <button onclick="closeBatchAddModal()" class="text-muted-foreground hover:text-foreground transition-colors"> |
| | <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 class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">名称前缀</label> |
| | <input type="text" id="batchKeyPrefix" placeholder="例如: 测试用户" |
| | class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"> |
| | </div> |
| | <div class="space-y-2"> |
| | <label class="text-sm font-medium text-muted-foreground">创建数量 (1-50)</label> |
| | <input type="number" id="batchKeyCount" value="10" min="1" max="50" |
| | class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"> |
| | </div> |
| | </div> |
| | <div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30"> |
| | <button onclick="closeBatchAddModal()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input bg-background hover:bg-accent h-9 px-5">取消</button> |
| | <button onclick="doBatchAddKeys()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">确认创建</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="modalBatchResult" 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"> |
| | <div class="flex items-center justify-between p-5 border-b border-border"> |
| | <h3 class="text-lg font-semibold">创建成功</h3> |
| | <button onclick="copyBatchResults()" class="text-sm text-primary hover:underline font-medium">点击复制全部</button> |
| | </div> |
| | <div class="p-5"> |
| | <div class="bg-muted p-4 rounded-md border border-border"> |
| | <textarea id="batchResultContent" readonly |
| | class="w-full h-80 bg-transparent border-none focus:ring-0 text-xs font-mono resize-none"></textarea> |
| | </div> |
| | </div> |
| | <div class="flex items-center justify-end p-5 border-t border-border bg-muted/30"> |
| | <button onclick="closeBatchResultModal()" |
| | class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">关闭</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="cacheViewerModal" 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-5xl shadow-xl overflow-hidden"> |
| | <div class="flex items-center justify-between p-4 border-b border-border"> |
| | <div class="min-w-0"> |
| | <h3 id="cacheViewerTitle" class="text-sm font-semibold truncate">缓存预览</h3> |
| | <div id="cacheViewerMeta" class="text-xs text-muted-foreground"></div> |
| | </div> |
| | <button onclick="closeCacheViewer()" |
| | class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-3 transition-colors">关闭</button> |
| | </div> |
| | <div id="cacheViewerBody" class="bg-black flex items-center justify-center p-4"></div> |
| | </div> |
| | </div> |
| |
|
| | <script> |
| | let allTokens = [], filteredTokens = [], selectedTokens = new Set(), allTagsList = []; |
| | let allLogsData = [], currentLogPage = 1, logsPerPage = 20; |
| | const selectedKeys = new Set(); |
| | const $ = (id) => document.getElementById(id); |
| | |
| | const apiRequest = async (u, o = {}) => { |
| | const t = localStorage.getItem('adminToken'); |
| | if (!t && !u.includes('/login')) return window.location.href = '/login'; |
| | if (t) o.headers = { ...o.headers, 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' }; |
| | const r = await fetch(u, o); |
| | if (r.status === 401 && !u.includes('/login')) return localStorage.removeItem('adminToken'), window.location.href = '/login'; |
| | return r; |
| | }; |
| | |
| | const showToast = (m, t = 'success') => { |
| | const d = document.createElement('div'); |
| | d.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg text-white text-sm z-[9999] transition-all duration-300 transform translate-x-full ${t === 'success' ? 'bg-green-600' : 'bg-red-600'}`; |
| | d.textContent = m; |
| | document.body.appendChild(d); |
| | requestAnimationFrame(() => d.classList.remove('translate-x-full')); |
| | setTimeout(() => { d.classList.add('translate-x-full'); setTimeout(() => d.remove(), 300) }, 3000); |
| | }; |
| | |
| | const logout = () => { localStorage.removeItem('adminToken'); window.location.href = '/login' }; |
| | |
| | const switchTab = (target) => { |
| | ['tokens', 'statistics', 'keys', 'logs', 'cache', 'settings'].forEach(name => { |
| | const cap = name.charAt(0).toUpperCase() + name.slice(1); |
| | const active = name === target; |
| | const panel = $(`panel${cap}`); |
| | const tab = $(`tab${cap}`); |
| | |
| | if (panel) panel.classList.toggle('hidden', !active); |
| | if (tab) { |
| | tab.classList.toggle('border-primary', active); |
| | tab.classList.toggle('text-primary', active); |
| | tab.classList.toggle('border-transparent', !active); |
| | tab.classList.toggle('text-muted-foreground', !active); |
| | } |
| | }); |
| | |
| | if (target === 'settings') loadSettings(); |
| | if (target === 'cache') loadCachePreview(true); |
| | if (target === 'statistics') loadRequestStats(); |
| | if (target === 'keys') loadKeys(); |
| | if (target === 'logs') loadLogs(); |
| | }; |
| | |
| | // --- Key 管理 --- |
| | |
| | const formatDate = (ts) => { |
| | if (!ts) return '-'; |
| | return new Date(ts * 1000).toLocaleString(); |
| | }; |
| | |
| | const loadKeys = async () => { |
| | try { |
| | const r = await apiRequest('/api/keys'); |
| | const d = await r.json(); |
| | if (d.success) { |
| | const tbody = $('keyTableBody'); |
| | tbody.innerHTML = ''; |
| | |
| | // 重置选择 |
| | selectedKeys.clear(); |
| | $('selectAllKeys').checked = false; |
| | updateBatchActionBar(); |
| | |
| | d.data.forEach(k => { |
| | const tr = document.createElement('tr'); |
| | tr.className = 'border-b border-border hover:bg-muted/50 transition-colors'; |
| | tr.innerHTML = ` |
| | <td class="p-4 align-middle"> |
| | <input type="checkbox" data-key="${k.key}" onchange="toggleKeySelection(this)" class="key-checkbox rounded border-border text-primary focus:ring-primary h-4 w-4"> |
| | </td> |
| | <td class="p-4 align-middle"> |
| | <span class="font-medium">${k.name || '-'}</span> |
| | <button onclick="editKeyName('${k.key}', '${k.name}')" class="ml-2 text-xs text-muted-foreground hover:text-primary"> |
| | <svg class="h-3 w-3 inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg> |
| | </button> |
| | </td> |
| | <td class="p-4 align-middle font-mono text-xs text-muted-foreground">${k.display_key}</td> |
| | <td class="p-4 align-middle text-muted-foreground">${formatDate(k.created_at)}</td> |
| | <td class="p-4 align-middle"> |
| | <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${k.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}"> |
| | ${k.is_active ? '启用' : '禁用'} |
| | </span> |
| | </td> |
| | <td class="p-4 align-middle text-right text-xs"> |
| | <button onclick="copyToClipboard('${k.key}')" class="btn-icon text-muted-foreground hover:text-primary mr-2" title="复制完整Key"> |
| | <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg> |
| | </button> |
| | <button onclick="toggleKeyStatus('${k.key}', ${!k.is_active})" class="btn-icon text-muted-foreground hover:text-primary mr-2" title="${k.is_active ? '禁用' : '启用'}"> |
| | <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg> |
| | </button> |
| | <button onclick="deleteKey('${k.key}')" class="btn-icon text-muted-foreground hover:text-destructive" title="删除"> |
| | <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"></path><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"></path></svg> |
| | </button> |
| | </td> |
| | `; |
| | tbody.appendChild(tr); |
| | }); |
| | } |
| | } catch (e) { console.error(e); showToast('加载Keys失败', 'error') } |
| | }; |
| | |
| | const openAddKeyModal = () => $('modalAddKey').classList.remove('hidden'); |
| | const closeAddKeyModal = () => { $('modalAddKey').classList.add('hidden'); $('newKeyName').value = '' }; |
| | |
| | const doAddKey = async () => { |
| | const name = $('newKeyName').value.trim(); |
| | if (!name) return showToast('请输入备注名称', 'error'); |
| | try { |
| | const r = await apiRequest('/api/keys/add', { method: 'POST', body: JSON.stringify({ name }) }); |
| | const d = await r.json(); |
| | if (d.success) { |
| | closeAddKeyModal(); |
| | showToast('Key创建成功'); |
| | loadKeys(); |
| | // 单个创建也弹窗显示一下方便复制 |
| | $('batchResultContent').value = d.data.key; |
| | $('modalBatchResult').classList.remove('hidden'); |
| | } else throw new Error(d.error); |
| | } catch (e) { showToast('创建失败: ' + e.message, 'error') } |
| | }; |
| | |
| | const openBatchAddModal = () => $('modalBatchAddKey').classList.remove('hidden'); |
| | const closeBatchAddModal = () => { $('modalBatchAddKey').classList.add('hidden'); $('batchKeyPrefix').value = ''; $('batchKeyCount').value = 10 }; |
| | |
| | const doBatchAddKeys = async () => { |
| | const prefix = $('batchKeyPrefix').value.trim(); |
| | const count = parseInt($('batchKeyCount').value); |
| | if (!prefix) return showToast('请输入名称前缀', 'error'); |
| | if (isNaN(count) || count < 1 || count > 50) return showToast('创建数量需在 1-50 之间', 'error'); |
| | |
| | try { |
| | const r = await apiRequest('/api/keys/batch-add', { method: 'POST', body: JSON.stringify({ name_prefix: prefix, count }) }); |
| | const d = await r.json(); |
| | if (d.success) { |
| | closeBatchAddModal(); |
| | showToast(`成功创建 ${d.data.length} 个 Key`); |
| | loadKeys(); |
| | // 显示结果 |
| | $('batchResultContent').value = d.data.map(k => k.key).join('\n'); |
| | $('modalBatchResult').classList.remove('hidden'); |
| | } else throw new Error(d.error); |
| | } catch (e) { showToast('批量创建失败: ' + e.message, 'error') } |
| | }; |
| | |
| | const closeBatchResultModal = () => $('modalBatchResult').classList.add('hidden'); |
| | const copyBatchResults = () => { |
| | const content = $('batchResultContent'); |
| | content.select(); |
| | document.execCommand('copy'); |
| | showToast('已复制全部 Key 到剪贴板', 'success'); |
| | }; |
| | |
| | const toggleSelectAllKeys = (el) => { |
| | const boxes = document.querySelectorAll('.key-checkbox'); |
| | boxes.forEach(cb => { |
| | cb.checked = el.checked; |
| | if (el.checked) selectedKeys.add(cb.dataset.key); |
| | else selectedKeys.delete(cb.dataset.key); |
| | }); |
| | updateBatchActionBar(); |
| | }; |
| | |
| | const toggleKeySelection = (el) => { |
| | if (el.checked) selectedKeys.add(el.dataset.key); |
| | else selectedKeys.delete(el.dataset.key); |
| | $('selectAllKeys').checked = selectedKeys.size === document.querySelectorAll('.key-checkbox').length; |
| | updateBatchActionBar(); |
| | }; |
| | |
| | const updateBatchActionBar = () => { |
| | const bar = $('batchActionBar'); |
| | const count = $('selectedCount'); |
| | if (count) count.textContent = selectedKeys.size; |
| | if (bar) bar.classList.toggle('hidden', selectedKeys.size === 0); |
| | }; |
| | |
| | const batchDeleteKeys = async () => { |
| | if (!selectedKeys.size || !confirm(`确定要删除选中的 ${selectedKeys.size} 个 Key 吗?此操作无法撤销!`)) return; |
| | try { |
| | const r = await apiRequest('/api/keys/batch-delete', { method: 'POST', body: JSON.stringify({ keys: Array.from(selectedKeys) }) }); |
| | const d = await r.json(); |
| | if (d.success) { showToast(d.message); loadKeys() } |
| | } catch (e) { showToast('批量删除失败', 'error') } |
| | }; |
| | |
| | const batchUpdateStatus = async (isActive) => { |
| | if (!selectedKeys.size) return; |
| | try { |
| | const r = await apiRequest('/api/keys/batch-status', { method: 'POST', body: JSON.stringify({ keys: Array.from(selectedKeys), is_active: isActive }) }); |
| | const d = await r.json(); |
| | if (d.success) { showToast(d.message); loadKeys() } |
| | } catch (e) { showToast('批量更新失败', 'error') } |
| | }; |
| | |
| | const deleteKey = async (key) => { |
| | if (!confirm('确定要删除这个 Key 吗?此操作无法撤销!')) return; |
| | try { |
| | const r = await apiRequest('/api/keys/delete', { method: 'POST', body: JSON.stringify({ key }) }); |
| | if ((await r.json()).success) { showToast('删除成功'); loadKeys() } |
| | } catch (e) { showToast('删除失败', 'error') } |
| | }; |
| | |
| | const toggleKeyStatus = async (key, isActive) => { |
| | try { |
| | await apiRequest('/api/keys/status', { method: 'POST', body: JSON.stringify({ key, is_active: isActive }) }); |
| | showToast('状态更新成功'); loadKeys(); |
| | } catch (e) { showToast('更新失败', 'error') } |
| | }; |
| | |
| | const editKeyName = async (key, oldName) => { |
| | const name = prompt("修改备注名称:", oldName); |
| | if (name && name !== oldName) { |
| | try { |
| | await apiRequest('/api/keys/name', { method: 'POST', body: JSON.stringify({ key, name }) }); |
| | showToast('备注更新成功'); loadKeys(); |
| | } catch (e) { showToast('更新失败', 'error') } |
| | } |
| | }; |
| | |
| | const copyToClipboard = (text) => { |
| | navigator.clipboard.writeText(text).then(() => showToast('已复制到剪贴板')).catch(() => showToast('复制失败', 'error')); |
| | }; |
| | |
| | // --- 日志审计 --- |
| | |
| | const loadLogs = async () => { |
| | try { |
| | const r = await apiRequest('/api/logs?limit=1000'); |
| | const d = await r.json(); |
| | if (d.success) { |
| | allLogsData = d.data; |
| | currentLogPage = 1; |
| | renderLogs(); |
| | } |
| | } catch (e) { console.error('加载日志失败:', e); showToast('加载日志失败', 'error') } |
| | }; |
| | |
| | const renderLogs = () => { |
| | const tbody = $('logTableBody'); |
| | tbody.innerHTML = ''; |
| | const total = allLogsData.length; |
| | $('logEmptyState').classList.toggle('hidden', total > 0); |
| | $('logPagination').classList.toggle('hidden', total === 0); |
| | |
| | const maxPage = Math.max(1, Math.ceil(total / logsPerPage)); |
| | if (currentLogPage > maxPage) currentLogPage = maxPage; |
| | |
| | $('logTotalCount').textContent = total; |
| | $('logCurrentPage').textContent = currentLogPage; |
| | $('logMaxPage').textContent = maxPage; |
| | $('logPrevBtn').disabled = currentLogPage <= 1; |
| | $('logNextBtn').disabled = currentLogPage >= maxPage; |
| | |
| | const start = (currentLogPage - 1) * logsPerPage; |
| | const end = start + logsPerPage; |
| | const pageData = allLogsData.slice(start, end); |
| | |
| | pageData.forEach(l => { |
| | const tr = document.createElement('tr'); |
| | tr.className = 'border-b border-border hover:bg-muted/50 transition-colors'; |
| | const statusClass = l.status === 200 ? 'text-green-600' : 'text-red-600'; |
| | tr.innerHTML = ` |
| | <td class="p-4 align-middle text-muted-foreground whitespace-nowrap">${l.time}</td> |
| | <td class="p-4 align-middle font-medium">${l.key_name}</td> |
| | <td class="p-4 align-middle text-xs">${l.model}</td> |
| | <td class="p-4 align-middle text-xs text-muted-foreground">${l.ip}</td> |
| | <td class="p-4 align-middle text-xs">${l.duration}s</td> |
| | <td class="p-4 align-middle ${statusClass} font-bold">${l.status}</td> |
| | <td class="p-4 align-middle"> |
| | ${l.error ? `<span class="text-red-500 text-xs" title="${l.error.replace(/"/g, '"')}">错误详情</span>` : '-'} |
| | </td> |
| | `; |
| | tbody.appendChild(tr); |
| | }); |
| | }; |
| | |
| | const changeLogPage = (delta) => { |
| | currentLogPage += delta; |
| | renderLogs(); |
| | $('logTableBody').closest('.overflow-auto').scrollTop = 0; |
| | }; |
| | |
| | const clearLogs = async () => { |
| | if (!confirm('确定要清空所有日志吗?')) return; |
| | try { |
| | await apiRequest('/api/logs/clear', { method: 'POST' }); |
| | showToast('日志已清空'); loadLogs(); |
| | } catch (e) { showToast('清空失败', 'error') } |
| | }; |
| | // checkAuth 已移除(使用新版 apiRequest) |
| | // apiRequest 已移除(使用新版) |
| | const loadStats = async () => { |
| | try { |
| | const r = await apiRequest('/api/stats'); |
| | if (!r) return; |
| | const d = await r.json(); |
| | if (d.success) { |
| | const s = d.data || {}; |
| | const normal = s.normal || {}; |
| | const superStats = s.super || {}; |
| | const sum = (key) => (normal[key] || 0) + (superStats[key] || 0); |
| | $('statTotal').textContent = s.total || 0; |
| | $('statActive').textContent = sum('active'); |
| | $('statUnused').textContent = sum('unused'); |
| | $('statLimited').textContent = sum('limited'); |
| | $('statExpired').textContent = sum('expired'); |
| | $('statCooldown').textContent = sum('cooldown'); |
| | $('statExhausted').textContent = sum('exhausted'); |
| | } |
| | } catch (e) { console.error('加载统计失败:', e) } |
| | }; |
| | const calcRemaining = () => { let n = 0, h = 0; allTokens.forEach(t => { if (t.remaining_queries > 0) n += t.remaining_queries; if (t.heavy_remaining_queries > 0) h += t.heavy_remaining_queries }); return { normal: n, heavy: h, total: n + h } }; |
| | const loadTokens = async () => { |
| | try { |
| | const r = await apiRequest('/api/tokens'); |
| | if (!r) return; |
| | const d = await r.json(); |
| | if (d.success) { |
| | allTokens = d.data.map(t => ({ |
| | ...t, |
| | tags: t.tags || [], |
| | note: t.note || '', |
| | cooldown_remaining: t.cooldown_remaining || 0, |
| | cooldown_until: t.cooldown_until || null, |
| | last_failure_time: t.last_failure_time || null, |
| | last_failure_reason: t.last_failure_reason || '', |
| | limit_reason: t.limit_reason || '' |
| | })); |
| | filteredTokens = allTokens; |
| | selectedTokens.clear(); |
| | renderTokens(); |
| | updateRemaining(); |
| | await loadAllTags(); |
| | } |
| | } catch (e) { console.error('加载列表失败:', e) } |
| | }; |
| | const updateRemaining = () => { const r = calcRemaining(); const chatTotal = r.total; const imageTotal = Math.floor(chatTotal / 2); $('statChatRemaining').textContent = chatTotal === 0 ? '-' : chatTotal.toLocaleString(); $('statImageRemaining').textContent = imageTotal === 0 ? '-' : imageTotal.toLocaleString(); $('statVideoRemaining').textContent = '无法统计' }; |
| | const formatDuration = (seconds) => { |
| | if (!seconds || seconds <= 0) return '-'; |
| | const sec = Math.floor(seconds % 60); |
| | const min = Math.floor(seconds / 60) % 60; |
| | const hour = Math.floor(seconds / 3600); |
| | const parts = []; |
| | if (hour) parts.push(`${hour}时`); |
| | if (min) parts.push(`${min}分`); |
| | if (!hour && !min) parts.push(`${sec}秒`); |
| | else if (sec) parts.push(`${sec}秒`); |
| | return parts.join(''); |
| | }; |
| | const formatDateTime = (ms) => { |
| | if (!ms) return '-'; |
| | return new Date(ms).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }); |
| | }; |
| | |
| | const renderTokens = () => { |
| | const tb = $('tokenTableBody'); |
| | const es = $('emptyState'); |
| | const ss = { |
| | '未使用': 'bg-muted text-muted-foreground', |
| | '冷却中': 'bg-orange-50 text-orange-700 border-orange-200', |
| | '额度耗尽': 'bg-amber-50 text-amber-700 border-amber-200', |
| | '限流中': 'bg-orange-50 text-orange-700 border-orange-200', |
| | '失效': 'bg-destructive/10 text-destructive border-destructive/20', |
| | '正常': 'bg-green-50 text-green-700 border-green-200' |
| | }; |
| | const ts = { sso: 'bg-blue-50 text-blue-700 border-blue-200', ssoSuper: 'bg-purple-50 text-purple-700 border-purple-200' }; |
| | const tl = { sso: 'SSO', ssoSuper: 'SuperSSO' }; |
| | const limitLabels = { cooldown: '429 冷却', exhausted: '额度耗尽' }; |
| | if (!filteredTokens.length) { tb.innerHTML = ''; es.classList.remove('hidden'); $('selectAll').checked = false; return updateBatchActions() } |
| | es.classList.add('hidden'); |
| | tb.innerHTML = filteredTokens.map(t => { |
| | const tagsHtml = t.tags && t.tags.length ? t.tags.map(tag => `<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">${tag}</span>`).join(' ') : '<span class="text-xs text-muted-foreground">-</span>'; |
| | const noteHtml = t.note && t.note.length ? `<span class="text-xs text-gray-700" title="${t.note}">${t.note.length > 20 ? t.note.substring(0, 20) + '...' : t.note}</span>` : '<span class="text-xs text-muted-foreground">-</span>'; |
| | const limitReasonText = limitLabels[t.limit_reason] || '-'; |
| | const cooldownText = t.cooldown_remaining ? formatDuration(t.cooldown_remaining) : '-'; |
| | const cooldownTitle = t.cooldown_until ? formatDateTime(t.cooldown_until) : ''; |
| | const limitInfoHtml = `<div class="text-xs ${limitReasonText === '-' ? 'text-muted-foreground' : 'text-gray-700'}"><div>${limitReasonText}</div><div${cooldownTitle ? ` title="${cooldownTitle}"` : ''}>${cooldownText}</div></div>`; |
| | const failureReason = t.last_failure_reason || '-'; |
| | const failureTitle = failureReason.replace(/"/g, '"'); |
| | const failureDisplay = failureReason.length > 24 ? failureReason.substring(0, 24) + '...' : failureReason; |
| | const failureTime = t.last_failure_time ? formatDateTime(t.last_failure_time) : '-'; |
| | const failureHtml = `<div class="text-xs ${failureReason === '-' ? 'text-muted-foreground' : 'text-gray-700'}" title="${failureTitle}">${failureDisplay}</div><div class="text-xs text-muted-foreground">${failureTime}</div>`; |
| | return `<tr class="transition-colors"><td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token) ? 'checked' : ''} onchange="toggleToken('${t.token}')"></td><td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0, 30)}...</span><button onclick="copyToken('${t.token.replace(/'/g, "\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="复制完整 Token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status] || ss['正常']}">${t.status}</span></td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries === -1 ? '-' : t.remaining_queries}</td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries === -1 ? '-' : t.heavy_remaining_queries}</td><td class="py-2.5 px-3 align-middle w-40">${limitInfoHtml}</td><td class="py-2.5 px-3 align-middle w-48">${failureHtml}</td><td class="py-2.5 px-3 align-middle w-32"><div class="flex flex-wrap gap-1">${tagsHtml}</div></td><td class="py-2.5 px-3 align-middle w-40">${noteHtml}</td><td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time ? new Date(t.created_time).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) : '-'}</td><td class="py-2.5 px-3 align-middle text-right w-28 sticky-right"><div class="flex items-center justify-end gap-1"><button onclick="testToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-blue-50 hover:text-blue-700 h-7 w-7" title="测试Token"><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="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></button><button onclick="editToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-7 w-7" title="编辑信息"><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="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7" title="删除"><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"><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></div></td></tr>`; |
| | }).join(''); |
| | updateBatchActions(); |
| | }; |
| | const toggleToken = t => selectedTokens[selectedTokens.has(t) ? 'delete' : 'add'](t) || updateBatchActions(); |
| | const toggleSelectAll = () => { const sa = $('selectAll'); sa.checked ? filteredTokens.forEach(t => selectedTokens.add(t.token)) : selectedTokens.clear(); renderTokens() }; |
| | const updateBatchActions = () => { const ba = $('batchActions'), sc = $('selectedCount'), c = selectedTokens.size; ba.classList[c > 0 ? 'add' : 'remove']('flex'); ba.classList[c > 0 ? 'remove' : 'add']('hidden'); c > 0 && (sc.textContent = `已选择 ${c} 项`); $('selectAll').checked = filteredTokens.length > 0 && c === filteredTokens.length }; |
| | const filterTokens = () => { const tf = $('filterType').value, sf = $('filterStatus').value, tagf = $('filterTag').value; filteredTokens = allTokens.filter(t => (tf === 'all' || t.token_type === tf) && (sf === 'all' || t.status === sf) && (tagf === 'all' || t.tags && t.tags.includes(tagf))); selectedTokens.clear(); renderTokens() }; |
| | const loadAllTags = async () => { try { const r = await apiRequest('/api/tokens/tags/all'); if (!r) return; const d = await r.json(); if (d.success) { allTagsList = d.data; const tagFilter = $('filterTag'); const currentValue = tagFilter.value; tagFilter.innerHTML = '<option value="all">全部标签</option>' + allTagsList.map(tag => `<option value="${tag}">${tag}</option>`).join(''); tagFilter.value = currentValue } } catch (e) { console.error('加载标签列表失败:', e) } }; |
| | const refreshTokens = async () => { await loadTokens(); await loadStats() }; |
| | const refreshAllTokens = async () => { |
| | if (!confirm('确定要刷新所有Token的剩余次数吗?这可能需要一段时间...')) return; |
| | |
| | try { |
| | const r = await apiRequest('/api/tokens/refresh-all', { method: 'POST' }); |
| | if (!r) return; |
| | const d = await r.json(); |
| | |
| | if (!d.success && d.data?.running) { |
| | showToast('刷新任务正在进行中,请稍后再试', 'error'); |
| | return; |
| | } |
| | |
| | if (!d.success) { |
| | showToast(d.message || '启动刷新失败', 'error'); |
| | return; |
| | } |
| | |
| | showToast('刷新任务已启动...', 'info'); |
| | await new Promise(resolve => setTimeout(resolve, 300)); |
| | |
| | while (true) { |
| | await new Promise(resolve => setTimeout(resolve, 500)); |
| | try { |
| | const pr = await apiRequest('/api/tokens/refresh-progress'); |
| | if (!pr) break; |
| | const pd = await pr.json(); |
| | if (pd.success && pd.data) { |
| | const { current, total, success, failed, running } = pd.data; |
| | |
| | if (!running) { |
| | showToast(`刷新完成: 成功 ${success} 个, 失败 ${failed} 个`, 'success'); |
| | break; |
| | } |
| | |
| | if (total > 0) { |
| | const pct = Math.round((current / total) * 100); |
| | showToast(`刷新进度: ${current}/${total} (${pct}%) | 成功: ${success} 失败: ${failed}`, 'info'); |
| | } |
| | } |
| | } catch (e) { break; } |
| | } |
| | |
| | await refreshTokens(); |
| | } catch (e) { |
| | showToast('刷新失败: ' + e.message, 'error'); |
| | } |
| | }; |
| | const openAddModal = () => $('addModal').classList.remove('hidden'); |
| | const closeAddModal = () => { $('addModal').classList.add('hidden'); $('addTokenList').value = '' }; |
| | const deleteToken = async (t, tt) => { if (!confirm('确定要删除这个 Token 吗?')) return; try { const r = await apiRequest('/api/tokens/delete', { method: 'POST', body: JSON.stringify({ tokens: [t], token_type: tt }) }); if (!r) return; const d = await r.json(); d.success ? await refreshTokens() : showToast('删除失败: ' + (d.error || '未知错误'), 'error') } catch (e) { showToast('删除失败: ' + e.message, 'error') } }; |
| | const batchDelete = async () => { if (!selectedTokens.size || !confirm(`确定要删除选中的 ${selectedTokens.size} 个 Token 吗?此操作不可恢复!`)) return; const tbt = { sso: [], ssoSuper: [] }; document.querySelectorAll('.token-checkbox:checked').forEach(cb => tbt[cb.dataset.type].push(cb.dataset.token)); try { const ps = [];['sso', 'ssoSuper'].forEach(k => tbt[k].length && ps.push(apiRequest('/api/tokens/delete', { method: 'POST', body: JSON.stringify({ tokens: tbt[k], token_type: k }) }))); await Promise.all(ps); await refreshTokens() } catch (e) { showToast('批量删除失败: ' + e.message, 'error') } }; |
| | const submitAddTokens = async () => { const tt = $('addTokenType').value, tks = $('addTokenList').value.split('\n').map(t => t.trim()).filter(t => t); if (!tks.length) return showToast('请输入至少一个 Token', 'error'); try { const r = await apiRequest('/api/tokens/add', { method: 'POST', body: JSON.stringify({ tokens: tks, token_type: tt }) }); if (!r) return; const d = await r.json(); d.success ? (closeAddModal(), await refreshTokens()) : showToast('添加失败: ' + (d.error || '未知错误'), 'error') } catch (e) { showToast('添加失败: ' + e.message, 'error') } }; |
| | const copyToken = async (t, e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(t); showToast('Token 已复制到剪贴板', 'success') } catch (err) { console.error('复制失败:', err); showToast('复制失败,请手动复制', 'error') } }; |
| | |
| | let currentEditToken = '', currentEditTokenType = ''; |
| | const editToken = (token, tokenType) => { currentEditToken = token; currentEditTokenType = tokenType; const tokenData = allTokens.find(t => t.token === token); const currentTags = tokenData?.tags || []; const currentNote = tokenData?.note || ''; $('editTokenInput').value = token.substring(0, 50) + '...'; $('editTagsInput').value = currentTags.join(', '); $('editNoteInput').value = currentNote; const suggestedContainer = $('suggestedTags'); suggestedContainer.innerHTML = ''; if (allTagsList.length > 0) { suggestedContainer.innerHTML = '<div class="text-xs text-muted-foreground mb-1">常用标签:</div>' + allTagsList.map(tag => `<button onclick="addTagToInput('${tag}')" class="inline-flex items-center rounded px-2 py-1 text-xs bg-muted hover:bg-accent transition-colors">${tag}</button>`).join('') } $('editTagsModal').classList.remove('hidden') }; |
| | const closeEditModal = () => { $('editTagsModal').classList.add('hidden'); currentEditToken = ''; currentEditTokenType = '' }; |
| | const addTagToInput = (tag) => { const input = $('editTagsInput'); const currentValue = input.value.trim(); const tags = currentValue ? currentValue.split(',').map(t => t.trim()) : []; if (!tags.includes(tag)) { tags.push(tag); input.value = tags.join(', ') } }; |
| | const submitEditInfo = async () => { if (!currentEditToken) return; const tagsInput = $('editTagsInput').value; const note = $('editNoteInput').value.trim(); const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t); const promises = []; promises.push(apiRequest('/api/tokens/tags', { method: 'POST', body: JSON.stringify({ token: currentEditToken, token_type: currentEditTokenType, tags }) })); promises.push(apiRequest('/api/tokens/note', { method: 'POST', body: JSON.stringify({ token: currentEditToken, token_type: currentEditTokenType, note }) })); try { const results = await Promise.all(promises); const allSuccess = results.every(r => r && r.ok); if (allSuccess) { closeEditModal(); await refreshTokens(); showToast('信息更新成功', 'success') } else { showToast('部分更新失败,请重试', 'error') } } catch (e) { showToast('更新失败: ' + e.message, 'error') } }; |
| | |
| | const testToken = async (token, tokenType) => { const btn = event.target.closest('button'); const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<svg class="h-3.5 w-3.5 animate-spin" 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>'; try { const r = await apiRequest('/api/tokens/test', { method: 'POST', body: JSON.stringify({ token, token_type: tokenType }) }); if (!r) return; const d = await r.json(); if (d.success && d.data.valid) { showToast(`Token有效!剩余: ${d.data.remaining_queries === -1 ? '无限制' : d.data.remaining_queries}次`, 'success'); await refreshTokens() } else { const errMsgs = { expired: 'Token已失效 (401错误)', blocked: '服务器被block,请稍后再试或更换IP', cooldown: 'Token处于冷却中', exhausted: 'Token额度耗尽' }; if (d.data?.error_type === 'cooldown' && d.data?.cooldown_remaining) { showToast(`Token冷却中,剩余 ${formatDuration(d.data.cooldown_remaining)}`, 'error') } else { showToast(errMsgs[d.data?.error_type] || 'Token无效或已失效', 'error') } } } catch (e) { showToast('测试失败: ' + e.message, 'error') } finally { btn.disabled = false; btn.innerHTML = originalHtml } }; |
| | |
| | const exportSelected = () => { if (!selectedTokens.size) return showToast('请先选择要导出的 Token', 'error'); const sd = allTokens.filter(t => selectedTokens.has(t.token)), limitLabels = { cooldown: '429 冷却', exhausted: '额度耗尽' }, csv = [['Token', '类型', '状态', '普通调用剩余', '高级调用剩余', '限流原因', '冷却剩余', '最近失败原因', '最近失败时间', '创建时间'].join(','), ...sd.map(t => { const limitReason = limitLabels[t.limit_reason] || '-'; const cooldown = t.cooldown_remaining ? formatDuration(t.cooldown_remaining) : '-'; const failureReason = t.last_failure_reason || '-'; const failureTime = t.last_failure_time ? formatDateTime(t.last_failure_time) : '-'; return [`"${t.token}"`, t.token_type === 'sso' ? 'SSO' : 'SuperSSO', t.status, t.remaining_queries === -1 ? '未使用' : t.remaining_queries, t.heavy_remaining_queries === -1 ? '未使用' : t.heavy_remaining_queries, limitReason, cooldown, `"${failureReason.replace(/"/g, '""')}"`, `"${failureTime}"`, `"${t.created_time ? new Date(t.created_time).toLocaleString('zh-CN') : '-'}"`].join(',') })].join('\n'), l = document.createElement('a'); l.href = URL.createObjectURL(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })); l.download = `grok_tokens_${new Date().toISOString().slice(0, 10)}.csv`; l.style.display = 'none'; document.body.appendChild(l); l.click(); document.body.removeChild(l); URL.revokeObjectURL(l.href); showToast(`已导出 ${selectedTokens.size} 个 Token`, 'success') } |
| | // showToast, logout, switchTab 已经在上方统一定义 |
| | |
| | const updateCacheProxyReadonly = () => { const proxyUrl = $('cfgProxyUrl').value.trim(), cacheProxyInput = $('cfgCacheProxyUrl'); if (proxyUrl) { cacheProxyInput.readOnly = false; cacheProxyInput.classList.remove('bg-muted'); cacheProxyInput.placeholder = 'socks5://username:password@127.0.0.1:7890' } else { cacheProxyInput.readOnly = true; cacheProxyInput.classList.add('bg-muted'); cacheProxyInput.value = ''; cacheProxyInput.placeholder = '设置服务代理后自动启用' } }; |
| | const updateStatsigIdState = () => { const dynamicToggle = $('cfgDynamicStatsig'), statsigInput = $('cfgStatsigId'); if (dynamicToggle.checked) { statsigInput.disabled = true; statsigInput.classList.add('bg-muted', 'text-muted-foreground'); statsigInput.placeholder = '已启用动态生成' } else { statsigInput.disabled = false; statsigInput.classList.remove('bg-muted', 'text-muted-foreground'); statsigInput.placeholder = '' } }; |
| | const loadRequestStats = async () => { |
| | try { |
| | const r = await apiRequest('/api/request-stats'); |
| | if (!r) return; |
| | const d = await r.json(); |
| | if (d.success) { |
| | const { hourly, daily, models, summary } = d.data; |
| | $('reqStatTotal').textContent = summary.total; |
| | $('reqStatSuccess').textContent = summary.success; |
| | $('reqStatFailed').textContent = summary.failed; |
| | $('reqStatRate').textContent = summary.success_rate + '%'; |
| | ['hourlyChart', 'dailyChart', 'modelsChart'].forEach(id => { |
| | const canvas = document.getElementById(id); |
| | if (canvas.chart) canvas.chart.destroy(); |
| | }); |
| | const ctxHourly = document.getElementById('hourlyChart').getContext('2d'); |
| | document.getElementById('hourlyChart').chart = new Chart(ctxHourly, { |
| | type: 'line', |
| | data: { |
| | labels: hourly.map(i => i.hour), |
| | datasets: [ |
| | { label: '成功', data: hourly.map(i => i.success), borderColor: '#16a34a', tension: 0.3 }, |
| | { label: '失败', data: hourly.map(i => i.failed), borderColor: '#dc2626', tension: 0.3 } |
| | ] |
| | }, |
| | options: { responsive: true, interaction: { mode: 'index', intersect: false } } |
| | }); |
| | const ctxDaily = document.getElementById('dailyChart').getContext('2d'); |
| | document.getElementById('dailyChart').chart = new Chart(ctxDaily, { |
| | type: 'bar', |
| | data: { |
| | labels: daily.map(i => i.date), |
| | datasets: [ |
| | { label: '成功', data: daily.map(i => i.success), backgroundColor: '#16a34a' }, |
| | { label: '失败', data: daily.map(i => i.failed), backgroundColor: '#dc2626' } |
| | ] |
| | }, |
| | options: { responsive: true, scales: { x: { stacked: true }, y: { stacked: true } } } |
| | }); |
| | const ctxModels = document.getElementById('modelsChart').getContext('2d'); |
| | document.getElementById('modelsChart').chart = new Chart(ctxModels, { |
| | type: 'doughnut', |
| | data: { |
| | labels: models.map(i => i.model), |
| | datasets: [{ |
| | data: models.map(i => i.count), |
| | backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#6366f1', '#14b8a6'] |
| | }] |
| | }, |
| | options: { responsive: true, plugins: { legend: { position: 'right' } } } |
| | }); |
| | } |
| | } catch (e) { console.error('加载统计失败:', e); showToast('加载统计失败', 'error') } |
| | }; |
| | const loadSettings = async () => { try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (d.success) { const g = d.data.global, k = d.data.grok; const cfClearance = k.cf_clearance || ''; const cleanCfDisplay = cfClearance.startsWith('cf_clearance=') ? cfClearance.split('cf_clearance=')[1] : cfClearance; $('cfgAdminUser').value = g.admin_username || ''; $('cfgAdminPass').value = ''; $('cfgLogLevel').value = g.log_level || 'DEBUG'; $('cfgImageCacheMaxSize').value = g.image_cache_max_size_mb || 500; $('cfgVideoCacheMaxSize').value = g.video_cache_max_size_mb || 1000; $('cfgImageMode').value = g.image_mode || 'url'; $('cfgBaseUrl').value = g.base_url || ''; $('cfgApiKey').value = k.api_key || ''; $('cfgProxyUrl').value = k.proxy_url || ''; $('cfgProxyPoolUrl').value = k.proxy_pool_url || ''; $('cfgProxyPoolInterval').value = k.proxy_pool_interval || 300; $('cfgCacheProxyUrl').value = k.cache_proxy_url || ''; $('cfgCfClearance').value = cleanCfDisplay; $('cfgStatsigId').value = k.x_statsig_id || ''; $('cfgDynamicStatsig').checked = k.dynamic_statsig !== false; updateStatsigIdState(); $('cfgFilteredTags').value = k.filtered_tags || ''; $('cfgShowThinking').value = k.show_thinking !== false ? 'true' : 'false'; $('cfgTemporary').value = k.temporary !== false ? 'true' : 'false'; $('cfgStreamChunkTimeout').value = k.stream_chunk_timeout || 120; $('cfgStreamFirstResponseTimeout').value = k.stream_first_response_timeout || 30; $('cfgStreamTotalTimeout').value = k.stream_total_timeout || 600; $('cfgRetryStatusCodes').value = (k.retry_status_codes || [401, 429]).join(','); updateCacheProxyReadonly(); await loadCacheSize() } } catch (e) { console.error('加载配置失败:', e); showToast('加载配置失败', 'error') } }; |
| | const loadCacheSize = async () => { try { const r = await apiRequest('/api/cache/size'); if (!r) return; const d = await r.json(); if (d.success) { ['image', 'video', 'total'].forEach(t => $(`${t}CacheSize`).value = d.data[`${t}_size`] || '0 MB') } } catch (e) { console.error('加载缓存大小失败:', e);['image', 'video', 'total'].forEach(t => $(`${t}CacheSize`).value = '0 MB') } }; |
| | const clearCacheByType = async (type, url, msg) => { if (!confirm(msg)) return; try { const r = await apiRequest(url, { method: 'POST' }); if (!r) return; const d = await r.json(); d.success ? (showToast(`${type}缓存清理完成,已删除 ${d.data.deleted_count || 0} 个文件`, 'success'), await loadCacheSize(), await loadCachePreview(true)) : showToast('清理失败: ' + (d.error || '未知错误'), 'error') } catch (e) { showToast('清理失败: ' + e.message, 'error') } }; |
| | const clearImageCache = () => clearCacheByType('图片', '/api/cache/clear/images', '确定要清理图片缓存吗?此操作将删除所有图片缓存文件!'); |
| | const clearVideoCache = () => clearCacheByType('视频', '/api/cache/clear/videos', '确定要清理视频缓存吗?此操作将删除所有视频缓存文件!'); |
| | const clearCache = () => clearCacheByType('', '/api/cache/clear', '确定要清理缓存吗?此操作将删除 /data/temp 目录中的所有文件!'); |
| | const cachePreviewState = { type: 'image', limit: 24, offset: 0, total: 0, items: [], loading: false }; |
| | const escapeHtml = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| | const updateCachePreviewMeta = () => { const meta = $('cachePreviewMeta'); if (!meta) return; const label = cachePreviewState.type === 'image' ? '图片' : '视频'; meta.textContent = `${label}缓存:${cachePreviewState.total || 0} 个` }; |
| | const updateCachePreviewTabs = () => { |
| | const imageTab = $('cacheTabImage'), videoTab = $('cacheTabVideo'); |
| | if (!imageTab || !videoTab) return; |
| | const isImage = cachePreviewState.type === 'image'; |
| | imageTab.classList.toggle('bg-primary', isImage); |
| | imageTab.classList.toggle('text-primary-foreground', isImage); |
| | imageTab.classList.toggle('bg-muted', !isImage); |
| | imageTab.classList.toggle('text-muted-foreground', !isImage); |
| | videoTab.classList.toggle('bg-primary', !isImage); |
| | videoTab.classList.toggle('text-primary-foreground', !isImage); |
| | videoTab.classList.toggle('bg-muted', isImage); |
| | videoTab.classList.toggle('text-muted-foreground', isImage); |
| | }; |
| | const updateCachePreviewLoadMore = () => { |
| | const btn = $('cacheLoadMoreBtn'); |
| | if (!btn) return; |
| | const hasMore = cachePreviewState.offset < cachePreviewState.total; |
| | const disabled = cachePreviewState.loading || !hasMore; |
| | btn.disabled = disabled; |
| | btn.textContent = cachePreviewState.loading ? '加载中...' : hasMore ? '加载更多' : '没有更多'; |
| | btn.classList.toggle('opacity-50', disabled); |
| | }; |
| | const renderCachePreview = () => { |
| | const grid = $('cachePreviewGrid'); |
| | if (!grid) return; |
| | if (!cachePreviewState.items.length) { |
| | const emptyText = cachePreviewState.loading ? '加载中...' : '暂无缓存文件'; |
| | grid.innerHTML = `<div class="col-span-full text-sm text-muted-foreground text-center py-6">${emptyText}</div>`; |
| | updateCachePreviewMeta(); |
| | updateCachePreviewLoadMore(); |
| | return; |
| | } |
| | grid.innerHTML = cachePreviewState.items.map(item => { |
| | const rawName = item.name || ''; |
| | const name = escapeHtml(rawName); |
| | const time = item.mtime ? new Date(item.mtime).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) : '-'; |
| | const metaText = `${item.size || '-'} • ${time}`; |
| | const meta = escapeHtml(metaText); |
| | const url = escapeHtml(item.url || ''); |
| | const type = cachePreviewState.type; |
| | const dataName = encodeURIComponent(rawName); |
| | const dataMeta = encodeURIComponent(metaText); |
| | const media = type === 'video' |
| | ? `<video src="${url}" class="w-full h-32 bg-black object-cover" preload="metadata" muted playsinline></video>` |
| | : `<img src="${url}" alt="${name}" loading="lazy" class="w-full h-32 object-cover bg-muted">`; |
| | return `<div class="cache-preview-item rounded-md border border-border bg-muted/20 overflow-hidden cursor-pointer" data-url="${url}" data-type="${type}" data-name="${dataName}" data-meta="${dataMeta}"><div>${media}</div><div class="p-2 text-xs"><div class="truncate" title="${name}">${name}</div><div class="text-muted-foreground">${meta}</div><button type="button" class="text-primary hover:underline" data-action="open">预览</button></div></div>`; |
| | }).join(''); |
| | bindCachePreviewClicks(); |
| | updateCachePreviewMeta(); |
| | updateCachePreviewLoadMore(); |
| | }; |
| | const bindCachePreviewClicks = () => { |
| | const grid = $('cachePreviewGrid'); |
| | if (!grid || grid.dataset.bound === '1') return; |
| | grid.dataset.bound = '1'; |
| | grid.addEventListener('click', (e) => { |
| | const item = e.target.closest('.cache-preview-item'); |
| | if (!item) return; |
| | const url = item.dataset.url || ''; |
| | const type = item.dataset.type || 'image'; |
| | const name = decodeURIComponent(item.dataset.name || ''); |
| | const meta = decodeURIComponent(item.dataset.meta || ''); |
| | openCacheViewer(url, type, name, meta); |
| | }); |
| | }; |
| | const openCacheViewer = (url, type, name, meta) => { |
| | const modal = $('cacheViewerModal'); |
| | const title = $('cacheViewerTitle'); |
| | const metaEl = $('cacheViewerMeta'); |
| | const body = $('cacheViewerBody'); |
| | if (!modal || !body) return; |
| | title.textContent = name || '缓存预览'; |
| | metaEl.textContent = meta || ''; |
| | body.innerHTML = ''; |
| | if (type === 'video') { |
| | const video = document.createElement('video'); |
| | video.src = url; |
| | video.controls = true; |
| | video.className = 'max-h-[70vh] w-auto max-w-full bg-black'; |
| | body.appendChild(video); |
| | } else { |
| | const img = document.createElement('img'); |
| | img.src = url; |
| | img.alt = name || '缓存图片'; |
| | img.className = 'max-h-[70vh] w-auto max-w-full object-contain'; |
| | body.appendChild(img); |
| | } |
| | modal.classList.remove('hidden'); |
| | }; |
| | const closeCacheViewer = () => { |
| | const modal = $('cacheViewerModal'); |
| | const body = $('cacheViewerBody'); |
| | if (body) body.innerHTML = ''; |
| | if (modal) modal.classList.add('hidden'); |
| | }; |
| | const initCacheViewer = () => { |
| | const modal = $('cacheViewerModal'); |
| | if (!modal || modal.dataset.bound === '1') return; |
| | modal.dataset.bound = '1'; |
| | modal.addEventListener('click', (e) => { if (e.target === modal) closeCacheViewer(); }); |
| | document.addEventListener('keydown', (e) => { |
| | if (e.key === 'Escape' && !modal.classList.contains('hidden')) closeCacheViewer(); |
| | }); |
| | }; |
| | const loadCachePreview = async (reset = false) => { |
| | if (cachePreviewState.loading) return; |
| | cachePreviewState.loading = true; |
| | if (reset) { |
| | cachePreviewState.offset = 0; |
| | cachePreviewState.total = 0; |
| | cachePreviewState.items = []; |
| | } |
| | updateCachePreviewTabs(); |
| | renderCachePreview(); |
| | try { |
| | const url = `/api/cache/list?type=${cachePreviewState.type}&limit=${cachePreviewState.limit}&offset=${cachePreviewState.offset}`; |
| | const r = await apiRequest(url); |
| | if (!r) return; |
| | const d = await r.json(); |
| | if (d.success && d.data) { |
| | const items = d.data.items || []; |
| | cachePreviewState.total = d.data.total || 0; |
| | cachePreviewState.items = reset ? items : cachePreviewState.items.concat(items); |
| | cachePreviewState.offset = (d.data.offset || 0) + items.length; |
| | } |
| | } catch (e) { |
| | console.error('加载缓存预览失败:', e); |
| | showToast('加载缓存预览失败', 'error'); |
| | } finally { |
| | cachePreviewState.loading = false; |
| | renderCachePreview(); |
| | } |
| | }; |
| | const switchCachePreviewType = (type) => { if (!['image', 'video'].includes(type) || cachePreviewState.type === type) return; cachePreviewState.type = type; loadCachePreview(true) }; |
| | const refreshCachePreview = () => loadCachePreview(true); |
| | const loadMoreCachePreview = () => loadCachePreview(false); |
| | const saveGlobalSettings = async () => { const gc = { admin_username: $('cfgAdminUser').value, log_level: $('cfgLogLevel').value, image_cache_max_size_mb: parseInt($('cfgImageCacheMaxSize').value) || 500, video_cache_max_size_mb: parseInt($('cfgVideoCacheMaxSize').value) || 1000, image_mode: $('cfgImageMode').value, base_url: $('cfgBaseUrl').value }; if ($('cfgAdminPass').value) gc.admin_password = $('cfgAdminPass').value; try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (!d.success) return showToast('加载配置失败', 'error'); const s = await apiRequest('/api/settings', { method: 'POST', body: JSON.stringify({ global_config: gc, grok_config: d.data.grok }) }); if (!s) return; const sd = await s.json(); sd.success ? (showToast('全局配置保存成功', 'success'), $('cfgAdminPass').value = '') : showToast('保存失败: ' + (sd.error || '未知错误'), 'error') } catch (e) { showToast('保存失败: ' + e.message, 'error') } }; |
| | const saveGrokSettings = async () => { const pu = $('cfgProxyUrl').value.trim(), cf = $('cfgCfClearance').value.trim(); const cleanCf = cf.startsWith('cf_clearance=') ? cf.split('cf_clearance=')[1] : cf; const retryCodesStr = $('cfgRetryStatusCodes').value.trim(); const retryCodes = retryCodesStr ? retryCodesStr.split(',').map(c => parseInt(c.trim())).filter(c => !isNaN(c)) : [401, 429]; const kc = { api_key: $('cfgApiKey').value, proxy_url: pu, proxy_pool_url: $('cfgProxyPoolUrl').value.trim(), proxy_pool_interval: parseInt($('cfgProxyPoolInterval').value) || 300, cache_proxy_url: pu ? $('cfgCacheProxyUrl').value : '', cf_clearance: cleanCf, x_statsig_id: $('cfgStatsigId').value, dynamic_statsig: $('cfgDynamicStatsig').checked, filtered_tags: $('cfgFilteredTags').value, show_thinking: $('cfgShowThinking').value === 'true', temporary: $('cfgTemporary').value === 'true', stream_chunk_timeout: parseInt($('cfgStreamChunkTimeout').value) || 120, stream_first_response_timeout: parseInt($('cfgStreamFirstResponseTimeout').value) || 30, stream_total_timeout: parseInt($('cfgStreamTotalTimeout').value) || 600, retry_status_codes: retryCodes }; try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (!d.success) return showToast('加载配置失败', 'error'); const s = await apiRequest('/api/settings', { method: 'POST', body: JSON.stringify({ global_config: d.data.global, grok_config: kc }) }); if (!s) return; const sd = await s.json(); sd.success ? showToast('Grok配置保存成功', 'success') : showToast('保存失败: ' + (sd.error || '未知错误'), 'error') } catch (e) { showToast('保存失败: ' + e.message, 'error') } }; |
| | const updateHoverCardPosition = c => { const t = c.querySelector('.hover-card-trigger'), ct = c.querySelector('.hover-card-content'); if (!t || !ct) return; const { top, bottom } = t.getBoundingClientRect(), h = window.innerHeight; ct.classList.remove('top', 'bottom'); const { visibility: v, opacity: o } = getComputedStyle(ct); Object.assign(ct.style, { visibility: 'hidden', opacity: '1' }); const ch = ct.offsetHeight; Object.assign(ct.style, { visibility: v, opacity: o }); ct.classList.add(top > ch + 10 ? 'top' : h - bottom > ch + 10 ? 'bottom' : 'top') }; |
| | const loadStorageMode = async () => { const modeConfig = { MYSQL: { classes: ['bg-blue-50', 'text-blue-700', 'border-blue-200'], tooltip: '数据库连接模式 - 数据持久化存储,修改配置时可能稍慢但更安全' }, REDIS: { classes: ['bg-purple-50', 'text-purple-700', 'border-purple-200'], tooltip: 'Redis缓存模式 - 高速内存存储,数据持久化且读写性能极佳' }, FILE: { classes: ['bg-green-50', 'text-green-700', 'border-green-200'], tooltip: '文件存储模式 - 本地文件存储,读写速度快' } }; const applyMode = (mode) => { $('storageModeText').textContent = mode; const config = modeConfig[mode] || modeConfig.FILE; $('storageMode').classList.add(...config.classes); $('storageModeTooltip').textContent = config.tooltip; updateHoverCardPosition($('storageMode').closest('.hover-card')) }; try { const r = await apiRequest('/api/storage/mode'); if (!r) return; const d = await r.json(); d.success && applyMode(d.data.mode) } catch (e) { console.error('加载存储模式失败:', e); applyMode('FILE') } }; |
| | window.addEventListener('DOMContentLoaded', () => { loadStorageMode(); refreshTokens(); initCacheViewer(); setInterval(() => { loadStats(); updateRemaining() }, 30000); window.addEventListener('resize', () => { const hoverCard = $('storageMode').closest('.hover-card'); hoverCard && updateHoverCardPosition(hoverCard) }); const hoverCard = $('storageMode').closest('.hover-card'), trigger = hoverCard?.querySelector('.hover-card-trigger'), content = hoverCard?.querySelector('.hover-card-content'); if (trigger && content) { trigger.addEventListener('mouseenter', () => { content.style.opacity = '1'; content.style.visibility = 'visible' }); trigger.addEventListener('mouseleave', () => { content.style.opacity = '0'; content.style.visibility = 'hidden' }) }; $('cfgProxyUrl').addEventListener('input', updateCacheProxyReadonly); $('cfgDynamicStatsig').addEventListener('change', updateStatsigIdState) }); |
| | </script> |
| | </body> |
| |
|
| | </html> |
| |
|