anuma2api / app /static /admin.html
li2895's picture
feat: 面板日志页(实时tail+过滤+高亮) + 生图链路耗时埋点(image_gen 每轮耗时/fence/换号)
4e58801
Raw
History Blame Contribute Delete
47.1 kB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>anuma2api · 管理面板</title>
<style>
:root {
--bg: #f4f6fb; --bg2: #eaeef5; --panel: #ffffff; --panel2: #f1f4f9; --border: #d9dfea;
--text: #1c2433; --muted: #67718a; --dim: #9aa3b5;
--accent: #3a6fe0; --accent2: #2f5fce; --accent-dim: rgba(58,111,224,.1);
--green: #1a9e57; --yellow: #c48a1a; --red: #d94141; --purple: #7b52e0;
--radius: 12px; --shadow: 0 6px 24px rgba(40,60,110,.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: radial-gradient(1200px 600px at 80% -10%, rgba(58,111,224,.06), transparent 60%), var(--bg);
color: var(--text);
font: 14px/1.55 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
min-height: 100vh; -webkit-font-smoothing: antialiased;
}
code { background: var(--panel2); border: 1px solid var(--border); border-radius: 5px; padding: 2px 7px; font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px; }
button {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 13px; font-weight: 500;
transition: all .15s; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-dim); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover:not(:disabled) { background: var(--accent2); border-color: var(--accent2); }
button.ok { color: var(--green); }
button.ok:hover:not(:disabled) { border-color: var(--green); background: rgba(53,199,119,.12); }
button.warn { color: var(--yellow); }
button.warn:hover:not(:disabled) { border-color: var(--yellow); background: rgba(236,201,75,.12); }
button.danger { color: var(--red); }
button.danger:hover:not(:disabled) { border-color: var(--red); background: rgba(241,96,96,.12); }
button.sm { padding: 5px 10px; font-size: 12px; }
button:disabled { opacity: .45; cursor: not-allowed; }
select {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 13px; cursor: pointer;
}
/* ---- 登录 ---- */
#page-login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.login-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 18px;
padding: 38px; width: min(420px, 100%); box-shadow: var(--shadow);
}
.login-card .logo { font-size: 26px; font-weight: 800; letter-spacing: -.5px; margin-bottom: 4px;
background: linear-gradient(120deg, var(--accent2), var(--purple)); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.login-card .sub { color: var(--muted); font-size: 13px; margin-bottom: 26px; }
.login-card label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 7px; }
.login-card input {
width: 100%; background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
color: var(--text); padding: 12px 14px; font-size: 14px; font-family: Consolas, monospace;
}
.login-card input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.login-card button { width: 100%; margin-top: 20px; padding: 12px; font-size: 14px; justify-content: center; }
/* ---- 主面板 ---- */
#page-main { display: none; min-height: 100vh; flex-direction: column; }
.topbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 15px 26px; border-bottom: 1px solid var(--border);
background: rgba(255,255,255,.85); backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 8; flex-wrap: wrap;
}
.brand { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); flex-shrink: 0; }
.dot.err { background: var(--red); box-shadow: 0 0 10px var(--red); }
.topbar-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.content { flex: 1; padding: 24px 26px; width: 100%; max-width: 1680px; margin: 0 auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.tab { background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
padding: 10px 18px; color: var(--muted); font-size: 14px; font-weight: 600; }
.tab:hover { color: var(--text); background: none; border-color: transparent; }
.tab.active { color: var(--accent2); border-bottom-color: var(--accent); }
.chart-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.chart-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; font-size: 14px; font-weight: 600; }
.legend { font-size: 12px; color: var(--muted); font-weight: 500; display: inline-flex; align-items: center; gap: 6px; }
.legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-left: 10px; vertical-align: -1px; }
.legend i.lg-in { background: var(--accent); } .legend i.lg-out { background: var(--purple); }
#usage-chart { width: 100%; height: 300px; display: block; }
.bar-pct { display: inline-block; height: 6px; background: var(--accent); border-radius: 3px; vertical-align: middle; }
.err-banner { background: rgba(241,96,96,.1); border: 1px solid var(--red); color: var(--red);
border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; font-size: 13px; display: none; }
.err-banner.show { display: block; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); gap: 14px; margin-bottom: 22px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px; position: relative; overflow: hidden; }
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .7; }
.card.c-ok::before { background: var(--green); } .card.c-warn::before { background: var(--yellow); }
.card.c-bad::before { background: var(--red); } .card.c-info::before { background: var(--purple); }
.card .num { font-size: 30px; font-weight: 700; line-height: 1; letter-spacing: -1px; }
.card .lbl { color: var(--muted); font-size: 12px; margin-top: 6px; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.bulk-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.bulk-info { color: var(--muted); font-size: 13px; margin-right: 4px; }
.bulk-info b { color: var(--accent2); }
.right-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-bar select, .filter-bar input[type="search"] {
background: var(--panel); color: var(--fg); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 10px; font-size: 13px; outline: none;
}
.filter-bar input[type="search"] { width: 200px; }
.filter-bar input[type="search"]::placeholder { color: var(--muted); }
.filter-bar select:focus, .filter-bar input[type="search"]:focus { border-color: var(--accent); }
.filter-count { color: var(--muted); font-size: 12px; white-space: nowrap; }
.log-box {
background: #0f1420; border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 14px; overflow: auto; max-height: 70vh;
}
.log-box pre {
margin: 0; color: #c7d1e0; font: 12px/1.6 Consolas, "JetBrains Mono", monospace;
white-space: pre-wrap; word-break: break-all;
}
.log-box .lg-warn { color: #e6b84c; }
.log-box .lg-err { color: #f07474; }
.table-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 860px; }
th, td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
th { background: var(--panel2); color: var(--muted); font-weight: 600; white-space: nowrap; position: sticky; top: 0; z-index: 1; }
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(40,60,110,.03); }
tbody tr.sel td { background: var(--accent-dim); }
.ck { width: 17px; height: 17px; cursor: pointer; accent-color: var(--accent); }
.col-ck { width: 42px; text-align: center; }
.col-act { text-align: right; white-space: nowrap; }
.badge { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.b-ok { background: rgba(53,199,119,.16); color: var(--green); }
.b-warn { background: rgba(236,201,75,.16); color: var(--yellow); }
.b-bad { background: rgba(241,96,96,.16); color: var(--red); }
.b-mute { background: rgba(139,148,167,.16); color: var(--muted); }
.muted { color: var(--muted); }
.dim { color: var(--dim); }
.ellipsis { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bal { font-family: Consolas, monospace; font-weight: 600; }
.bal.pos { color: var(--green); } .bal.zero { color: var(--red); } .bal.unk { color: var(--dim); }
.empty-row td { padding: 46px; text-align: center; color: var(--muted); }
.row-btns { display: inline-flex; gap: 6px; }
/* ---- 弹窗 ---- */
.modal-mask { position: fixed; inset: 0; background: rgba(30,40,70,.4); display: none;
align-items: center; justify-content: center; z-index: 40; padding: 20px; backdrop-filter: blur(3px); }
.modal-mask.open { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
width: min(640px, 100%); max-height: 90vh; overflow: auto; padding: 24px; box-shadow: var(--shadow); }
.modal h2 { font-size: 18px; margin-bottom: 14px; }
.modal pre { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
padding: 15px; font-size: 12px; overflow: auto; max-height: 56vh; white-space: pre-wrap; word-break: break-all;
font-family: Consolas, monospace; }
.close-x { float: right; cursor: pointer; color: var(--muted); font-size: 22px; line-height: 1; }
.close-x:hover { color: var(--text); }
.modal label { display: block; color: var(--muted); font-size: 12px; margin: 12px 0 6px; }
.modal textarea { width: 100%; background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
color: var(--text); padding: 12px; font-size: 13px; font-family: Consolas, monospace; min-height: 170px; resize: vertical; }
.modal textarea:focus { outline: none; border-color: var(--accent); }
.form-actions { margin-top: 20px; display: flex; gap: 8px; justify-content: flex-end; }
.toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%) translateY(10px);
background: var(--panel2); border: 1px solid var(--border); padding: 12px 22px;
border-radius: 10px; font-size: 13px; opacity: 0; pointer-events: none; transition: all .25s; z-index: 60; box-shadow: var(--shadow); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { border-color: var(--red); color: var(--red); }
.toast.ok { border-color: var(--green); color: var(--green); }
.spin { display: inline-block; width: 13px; height: 13px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: sp .7s linear infinite; }
@keyframes sp { to { transform: rotate(360deg); } }
@media (max-width: 680px) {
.topbar { padding: 12px 16px; } .content { padding: 16px; }
.card .num { font-size: 25px; } .toolbar { flex-direction: column; align-items: stretch; }
}
</style>
</head>
<body>
<!-- 登录 -->
<div id="page-login">
<div class="login-card">
<div class="logo">anuma2api</div>
<p class="sub">管理面板 · 输入 admin 鉴权 key(<code>[admin].auth_key</code></p>
<form id="login-form">
<label for="key">Admin Key</label>
<input id="key" type="password" placeholder="your admin key" autocomplete="off" autofocus>
<button class="primary" type="submit" id="login-btn">进入面板</button>
</form>
</div>
</div>
<!-- 主面板 -->
<div id="page-main">
<div class="topbar">
<div class="brand"><span class="dot" id="status-dot"></span>anuma2api 管理面板</div>
<div class="topbar-actions">
<span class="muted" style="font-size:12px">自动刷新</span>
<select id="refresh-rate">
<option value="10000">10s</option>
<option value="30000" selected>30s</option>
<option value="60000">60s</option>
<option value="0">手动</option>
</select>
<button id="btn-refresh">↻ 刷新</button>
<button id="btn-logout">退出</button>
</div>
</div>
<div class="content">
<div class="err-banner" id="err-banner"></div>
<div class="tabs">
<button class="tab active" data-tab="accounts">账号管理</button>
<button class="tab" data-tab="usage">用量统计</button>
<button class="tab" data-tab="apikeys">API Key</button>
<button class="tab" data-tab="logs">日志</button>
</div>
<!-- ===== 账号管理视图 ===== -->
<div id="view-accounts">
<div class="cards">
<div class="card c-info"><div class="num" id="st-total"></div><div class="lbl">全部账号</div></div>
<div class="card c-ok"><div class="num" id="st-active"></div><div class="lbl">可用</div></div>
<div class="card c-warn"><div class="num" id="st-cooldown"></div><div class="lbl">冷却中</div></div>
<div class="card c-bad"><div class="num" id="st-disabled"></div><div class="lbl">禁用/失败</div></div>
<div class="card c-bad"><div class="num" id="st-quota"></div><div class="lbl">配额耗尽</div></div>
<div class="card c-info"><div class="num bal" id="st-balance"></div><div class="lbl">总余额 <span class="dim">(已查)</span></div></div>
</div>
<div class="toolbar">
<div class="bulk-bar">
<span class="bulk-info">已选 <b id="sel-count">0</b></span>
<button class="ok sm" id="bulk-enable" disabled>✓ 启用</button>
<button class="warn sm" id="bulk-disable" disabled>⊘ 禁用</button>
<button class="sm" id="bulk-balance" disabled>💰 查余额</button>
<button class="danger sm" id="bulk-delete" disabled>🗑 删除</button>
</div>
<div class="filter-bar">
<select id="filter-status" title="按状态筛选">
<option value="all">全部状态</option>
<option value="active">可用</option>
<option value="cooldown">冷却中</option>
<option value="quota">配额耗尽</option>
<option value="disabled">禁用/失败</option>
</select>
<input type="search" id="search-box" placeholder="🔍 搜索名称 / 邮箱…" autocomplete="off">
<span class="filter-count" id="filter-count"></span>
</div>
<div class="right-tools">
<button id="btn-balance-all">💰 查全部余额</button>
<button id="btn-upload">📤 上传账号文件</button>
<button id="btn-add">+ 新增账号</button>
<button id="btn-reload">⟳ 重载磁盘</button>
</div>
</div>
<div class="table-wrap">
<div class="table-scroll">
<table>
<thead>
<tr>
<th class="col-ck"><input type="checkbox" class="ck" id="ck-all" title="全选"></th>
<th>名称</th><th>状态</th><th>余额</th><th>邮箱</th><th>创建时间</th><th>失败原因</th><th class="col-act">操作</th>
</tr>
</thead>
<tbody id="tb"></tbody>
</table>
</div>
</div>
</div><!-- /view-accounts -->
<!-- ===== 用量统计视图 ===== -->
<div id="view-usage" style="display:none">
<div class="toolbar">
<div class="bulk-bar">
<span class="bulk-info">时间范围</span>
<select id="usage-window">
<option value="24h">最近 24 小时</option>
<option value="3d">最近 3 天</option>
<option value="7d">最近 7 天</option>
<option value="30d">最近 30 天</option>
</select>
<button class="sm" id="usage-refresh">↻ 刷新</button>
</div>
</div>
<div class="cards">
<div class="card c-info"><div class="num" id="u-total"></div><div class="lbl">总 tokens</div></div>
<div class="card c-ok"><div class="num" id="u-input"></div><div class="lbl">输入 tokens</div></div>
<div class="card c-warn"><div class="num" id="u-output"></div><div class="lbl">输出 tokens</div></div>
<div class="card c-info"><div class="num" id="u-requests"></div><div class="lbl">请求数</div></div>
</div>
<div class="chart-card">
<div class="chart-head">
<span>Token 用量趋势</span>
<span class="legend"><i class="lg-in"></i>输入 <i class="lg-out"></i>输出</span>
</div>
<canvas id="usage-chart"></canvas>
</div>
<div class="table-wrap" style="margin-top:20px">
<div class="table-scroll">
<table>
<thead><tr><th>模型</th><th>输入</th><th>输出</th><th>总计</th><th>请求数</th><th>占比</th></tr></thead>
<tbody id="u-tb"></tbody>
</table>
</div>
</div>
</div><!-- /view-usage -->
<!-- ===== API Key 视图 ===== -->
<div id="view-apikeys" style="display:none">
<div class="toolbar">
<div class="bulk-bar">
<span class="bulk-info">API Key 总数 <b id="ak-total"></b></span>
<button class="sm" id="ak-refresh">↻ 刷新</button>
</div>
<div class="filter-bar">
<input type="text" id="ak-name" placeholder="key 名称(可选)" autocomplete="off">
<button class="primary sm" id="ak-generate">+ 生成 Key</button>
</div>
</div>
<div class="table-wrap">
<div class="table-scroll">
<table>
<thead><tr><th>名称</th><th>Key</th><th>创建时间</th><th>最近使用</th><th class="col-act">操作</th></tr></thead>
<tbody id="ak-tb"></tbody>
</table>
</div>
</div>
<p class="muted" style="font-size:12px;margin-top:10px">生成的 Key 用于客户端(Cherry Studio 等)连接 <code>/v1/*</code><code>Authorization: Bearer &lt;key&gt;</code>;config 里的 <code>[gateway] api_key</code> 兼容并存。</p>
</div><!-- /view-apikeys -->
<!-- ===== 日志视图 ===== -->
<div id="view-logs" style="display:none">
<div class="toolbar">
<div class="bulk-bar">
<span class="bulk-info">网关日志(尾部 <b id="lg-count"></b> 行)</span>
<button class="sm" id="lg-refresh">↻ 刷新</button>
<label class="muted" style="font-size:12px;display:flex;align-items:center;gap:4px">
<input type="checkbox" id="lg-auto" checked> 自动刷新
</label>
</div>
<div class="filter-bar">
<input type="search" id="lg-search" placeholder="🔍 过滤关键字(image/fence/error…)" autocomplete="off">
</div>
</div>
<div class="log-box">
<pre id="lg-pre">加载中…</pre>
</div>
</div><!-- /view-logs -->
</div>
</div>
<!-- 详情弹窗 -->
<div class="modal-mask" id="detail-mask">
<div class="modal">
<span class="close-x" data-close="1"></span>
<h2 id="detail-title">账号详情</h2>
<pre id="detail-json"></pre>
<div class="form-actions"><button data-close="1">关闭</button></div>
</div>
</div>
<!-- 新增弹窗 -->
<div class="modal-mask" id="add-mask">
<div class="modal">
<span class="close-x" data-close="1"></span>
<h2>新增 / 更新账号</h2>
<p class="muted" style="font-size:12px">JSON 写入 <code>account/&lt;name&gt;.json</code>。必填 <code>name</code>;anuma 凭据:<code>privy_identity_token</code>(authorization) / <code>privy_app_id</code> / <code>wallet_address</code> / <code>source_email</code> / <code>conversation_id</code>(可选)</p>
<form id="add-form">
<label for="add-account">账号 JSON</label>
<textarea id="add-account" placeholder='{"name":"my-account","source_email":"a@b.xyz","privy_identity_token":"...","privy_app_id":"...","wallet_address":"0x..."}'></textarea>
<div class="form-actions">
<button type="button" data-close="1">取消</button>
<button type="submit" class="primary" id="add-save">保存</button>
</div>
</form>
</div>
</div>
<!-- 上传账号文件弹窗 -->
<div class="modal-mask" id="upload-mask">
<div class="modal">
<span class="close-x" data-close="1"></span>
<h2>上传账号文件</h2>
<p class="muted" style="font-size:12px">选择本地注册机生成的 <code>account/*.json</code>(可多选),或打包好的 <code>.zip</code>(含多个 json 一并上传)。同名账号会覆盖更新,上传后立即热加载无需重启。</p>
<label for="upload-file">账号文件</label>
<input type="file" id="upload-file" multiple accept=".json,.zip">
<div class="form-actions">
<button type="button" data-close="1">取消</button>
<button type="button" class="primary" id="upload-submit">上传</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(() => {
'use strict';
const el = id => document.getElementById(id);
const KEY = 'anuma2api.adminKey';
let key = localStorage.getItem(KEY) || '';
let timer = null;
let accounts = []; // 最近一次列表
const selected = new Set(); // 选中的账号名
const balances = {}; // name -> {balance, error}
let filterStatus = 'all'; // 状态筛选:all/active/cooldown/quota/disabled
let searchTerm = ''; // 搜索词(名称/邮箱)
const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(msg, cls) {
const t = el('toast');
t.textContent = msg;
t.className = 'toast show ' + (cls || '');
clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 2800);
}
function showErr(m) { const b = el('err-banner'); b.textContent = m; b.classList.add('show'); }
function hideErr() { el('err-banner').classList.remove('show'); }
async function api(path, opts) {
opts = opts || {};
const headers = { 'Authorization': 'Bearer ' + key, ...(opts.headers || {}) };
if (opts.body && !(opts.body instanceof FormData)) headers['Content-Type'] = 'application/json';
const r = await fetch(path, { ...opts, headers });
if (r.status === 401) { key=''; localStorage.removeItem(KEY); showLogin(); throw new Error('key 无效或已失效'); }
if (r.status === 404 && path.startsWith('/admin/accounts')) { showErr('管理后台未启用:config.toml [admin].auth_key 为空'); throw new Error('admin disabled'); }
if (!r.ok) {
let d=''; try { d=(await r.json()).detail||''; } catch(e){ try { d=await r.text(); } catch(_){} }
throw new Error(r.status + (d ? ' '+d : ''));
}
const ct = r.headers.get('content-type') || '';
return ct.includes('json') ? r.json() : r.text();
}
function matchStatus(a) {
if (filterStatus === 'all') return true;
if (filterStatus === 'active') {
return !a.disabled && !a.fail_reason && !(a.cooldown_seconds > 0);
}
if (filterStatus === 'cooldown') {
return !a.disabled && !a.fail_reason && a.cooldown_seconds > 0;
}
if (filterStatus === 'quota') return a.fail_reason === 'quota_exhausted';
if (filterStatus === 'disabled') return !!(a.disabled || a.fail_reason);
return true;
}
function matchSearch(a) {
if (!searchTerm) return true;
const q = searchTerm.toLowerCase();
return (a.name||'').toLowerCase().includes(q)
|| (a.source_email||'').toLowerCase().includes(q);
}
function statusBadge(a) {
if (a.disabled) return '<span class="badge b-bad">disabled</span>';
if (a.fail_reason === 'quota_exhausted') return '<span class="badge b-bad">quota</span>';
if (a.fail_reason) return '<span class="badge b-bad">'+esc(a.fail_reason)+'</span>';
if (a.cooldown_seconds > 0) return '<span class="badge b-warn">冷却 '+Math.ceil(a.cooldown_seconds/60)+'m</span>';
return '<span class="badge b-ok">active</span>';
}
function balCell(name) {
const b = balances[name];
if (!b) return '<span class="bal unk">–</span>';
if (b.balance === null || b.balance === undefined) return '<span class="bal unk" title="'+esc(b.error||'')+'">?</span>';
const cls = b.balance > 0 ? 'pos' : 'zero';
return '<span class="bal '+cls+'">'+b.balance+'</span>';
}
function render() {
const now = Date.now();
const st = { total: accounts.length, active:0, cooldown:0, disabled:0, quota:0 };
for (const a of accounts) {
if (a.fail_reason === 'quota_exhausted') st.quota++;
if (a.disabled) st.disabled++;
else if (a.fail_reason) st.disabled++;
else if (a.cooldown_seconds > 0) st.cooldown++;
else st.active++;
}
el('st-total').textContent = st.total;
el('st-active').textContent = st.active;
el('st-cooldown').textContent = st.cooldown;
el('st-disabled').textContent = st.disabled;
el('st-quota').textContent = st.quota;
const totBal = Object.values(balances).reduce((s,b)=> s + (typeof b.balance==='number'?b.balance:0), 0);
const anyBal = Object.keys(balances).length > 0;
el('st-balance').textContent = anyBal ? totBal : '–';
const tb = el('tb');
const visible = accounts.filter(a => matchStatus(a) && matchSearch(a));
el('filter-count').textContent = visible.length ? (visible.length + ' / ' + accounts.length) : '';
if (!accounts.length) {
tb.innerHTML = '<tr class="empty-row"><td colspan="8">无账号(account/ 目录为空)</td></tr>';
} else if (!visible.length) {
tb.innerHTML = '<tr class="empty-row"><td colspan="8">没有符合筛选/搜索的账号</td></tr>';
} else {
tb.innerHTML = visible.map(a => {
const on = selected.has(a.name);
return '<tr class="'+(on?'sel':'')+'" data-name="'+esc(a.name)+'">'+
'<td class="col-ck"><input type="checkbox" class="ck row-ck" data-name="'+esc(a.name)+'"'+(on?' checked':'')+'></td>'+
'<td><code>'+esc(a.name)+'</code></td>'+
'<td>'+statusBadge(a)+'</td>'+
'<td>'+balCell(a.name)+'</td>'+
'<td class="ellipsis">'+esc(a.source_email||'—')+'</td>'+
'<td class="muted">'+esc(a.created_at||'—')+'</td>'+
'<td class="muted">'+esc(a.fail_reason||'')+'</td>'+
'<td class="col-act"><span class="row-btns">'+
(a.disabled || a.fail_reason
? '<button class="ok sm" data-act="enable" data-name="'+esc(a.name)+'">启用</button>'
: '<button class="warn sm" data-act="disable" data-name="'+esc(a.name)+'">禁用</button>')+
'<button class="sm" data-act="bal" data-name="'+esc(a.name)+'">余额</button>'+
'<button class="sm" data-act="detail" data-name="'+esc(a.name)+'">详情</button>'+
'<button class="danger sm" data-act="del" data-name="'+esc(a.name)+'">删</button>'+
'</span></td></tr>';
}).join('');
}
syncSelUI();
}
function syncSelUI() {
el('sel-count').textContent = selected.size;
const has = selected.size > 0;
['bulk-enable','bulk-disable','bulk-balance','bulk-delete'].forEach(id => el(id).disabled = !has);
const visible = accounts.filter(a => matchStatus(a) && matchSearch(a));
const names = visible.map(a=>a.name);
const allOn = names.length>0 && names.every(n=>selected.has(n));
const ckAll = el('ck-all');
ckAll.checked = allOn;
ckAll.indeterminate = has && !allOn;
}
async function refresh() {
hideErr();
try {
const data = await api('/admin/accounts');
accounts = (data.data||[]).slice().sort((a,b)=>(a.disabled-b.disabled)||(a.name||'').localeCompare(b.name||''));
// 清理已不存在账号的选中态/余额
const live = new Set(accounts.map(a=>a.name));
for (const n of [...selected]) if (!live.has(n)) selected.delete(n);
render();
el('status-dot').classList.remove('err');
} catch (e) {
el('status-dot').classList.add('err');
if (e.message !== 'key 无效或已失效' && e.message !== 'admin disabled') showErr(e.message);
}
}
async function loadDetail(name) {
try {
const acc = await api('/admin/accounts/'+encodeURIComponent(name));
el('detail-title').textContent = '账号详情 · ' + name;
el('detail-json').textContent = JSON.stringify(acc, null, 2);
open('detail-mask');
} catch (e) { toast(e.message, 'err'); }
}
async function setEnabled(name, enable) {
try {
await api('/admin/accounts/'+encodeURIComponent(name)+(enable?'/enable':'/disable'), { method:'POST' });
toast((enable?'已启用 ':'已禁用 ')+name, 'ok');
refresh();
} catch (e) { toast(e.message, 'err'); }
}
async function del(name) {
if (!confirm('确认删除账号 '+name+'?会从磁盘删除 account/'+name+'.json')) return;
try { await api('/admin/accounts/'+encodeURIComponent(name), {method:'DELETE'}); toast('已删除 '+name,'ok'); selected.delete(name); refresh(); }
catch (e) { toast(e.message, 'err'); }
}
async function fetchBal(name) {
try {
const r = await api('/admin/accounts/'+encodeURIComponent(name)+'/balance');
balances[name] = { balance: r.balance, error: r.error };
render();
if (r.error) toast(name+': '+r.error, 'err');
} catch (e) { toast(e.message, 'err'); }
}
// 批量
async function bulk(kind) {
const names = [...selected];
if (!names.length) return;
if (kind === 'delete' && !confirm('确认删除选中的 '+names.length+' 个账号?此操作不可恢复')) return;
const map = { enable:'/admin/accounts/enable', disable:'/admin/accounts/disable', delete:'/admin/accounts/delete' };
try {
const r = await api(map[kind], { method:'POST', body: JSON.stringify({ names }) });
toast('操作成功 '+(r.count||0)+' 个'+(r.not_found&&r.not_found.length?','+r.not_found.length+' 个未找到':''), 'ok');
if (kind === 'delete') names.forEach(n=>selected.delete(n));
refresh();
} catch (e) { toast(e.message, 'err'); }
}
async function bulkBalance(names, btnId) {
const btn = btnId && el(btnId);
const old = btn && btn.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spin"></span> 查询中'; }
try {
const r = await api('/admin/accounts/balance', { method:'POST', body: JSON.stringify({ names: names||[] }) });
for (const item of (r.data||[])) balances[item.name] = { balance: item.balance, error: item.error };
render();
const okN = (r.data||[]).filter(x=>typeof x.balance==='number').length;
toast('已查 '+(r.count||0)+' 个,成功 '+okN+',总余额 '+(r.total_balance||0), 'ok');
} catch (e) { toast(e.message, 'err'); }
finally { if (btn) { btn.disabled = false; btn.innerHTML = old; } }
}
async function reload() {
el('btn-reload').disabled = true;
try { const r = await api('/admin/reload', {method:'POST'}); toast('已重载 '+(r.count||0)+' 个账号','ok'); refresh(); }
catch (e) { toast(e.message, 'err'); }
finally { el('btn-reload').disabled = false; }
}
// ---- API Key 管理 ----
let apiKeys = [];
async function loadApiKeys() {
try {
const d = await api('/admin/apikeys');
apiKeys = (d.data||[]).slice().sort((a,b)=>(b.created_at||'').localeCompare(a.created_at||''));
el('ak-total').textContent = apiKeys.length;
el('ak-tb').innerHTML = apiKeys.length
? apiKeys.map(k => {
const masked = (k.key||'').slice(0,8) + '…' + (k.key||'').slice(-4);
return '<tr>'+
'<td>'+esc(k.name||'—')+'</td>'+
'<td><code class="ak-code" title="完整 key:点击复制">'+esc(masked)+'</code></td>'+
'<td class="muted">'+esc(k.created_at||'—')+'</td>'+
'<td class="muted">'+(k.last_used ? new Date(k.last_used*1000).toLocaleString() : '从未')+'</td>'+
'<td class="col-act"><span class="row-btns">'+
'<button class="ok sm" data-ak="copy" data-key="'+esc(k.key||'')+'">复制</button>'+
'<button class="danger sm" data-ak="del" data-key="'+esc(k.key||'')+'">删</button>'+
'</span></td>'+
'</tr>';
}).join('')
: '<tr class="empty-row"><td colspan="5">暂无 API Key,点「+ 生成 Key」创建</td></tr>';
} catch (e) { if (e.message!=='key 无效或已失效') toast(e.message,'err'); }
}
async function generateKey() {
const name = el('ak-name').value.trim();
try {
const k = await api('/admin/apikeys?name='+encodeURIComponent(name), {method:'POST'});
el('ak-name').value = '';
toast('已生成:'+k.key, 'ok');
navigator.clipboard && navigator.clipboard.writeText(k.key).catch(()=>{});
loadApiKeys();
} catch (e) { toast(e.message,'err'); }
}
async function deleteApiKey(key) {
if (!confirm('确认删除此 API Key?使用它的客户端将立即失效')) return;
try {
const r = await api('/admin/apikeys/delete', {method:'POST', body:JSON.stringify({names:[key]})});
toast('已删除 '+(r.count||0)+' 个', 'ok');
loadApiKeys();
} catch (e) { toast(e.message,'err'); }
}
el('ak-generate').addEventListener('click', generateKey);
el('ak-name').addEventListener('keydown', e => { if (e.key==='Enter') generateKey(); });
el('ak-refresh').addEventListener('click', loadApiKeys);
el('ak-tb').addEventListener('click', e => {
const btn = e.target.closest('button[data-ak]'); if (!btn) return;
if (btn.dataset.ak === 'del') deleteApiKey(btn.dataset.key);
if (btn.dataset.ak === 'copy') {
const key = btn.dataset.key || '';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(key).then(() => toast('已复制 '+key.slice(0,8)+'…', 'ok'), () => fallbackCopy(key));
} else fallbackCopy(key);
function fallbackCopy(txt) {
const ta = document.createElement('textarea');
ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); toast('已复制 '+txt.slice(0,8)+'…', 'ok'); }
catch (_) { toast('复制失败,请手动复制', 'err'); }
document.body.removeChild(ta);
}
}
});
// ---- 日志 ----
let logTimer = null;
let logLines = [];
let logFilter = '';
function lgHighlight(line) {
const escL = esc(line);
if (/WARNING|WARN|FENCED|fenced|超时|timeout/i.test(line)) return '<span class="lg-warn">'+escL+'</span>';
if (/ERROR|error|Traceback|502|failed/i.test(line)) return '<span class="lg-err">'+escL+'</span>';
return escL;
}
function lgRender() {
const rows = logFilter ? logLines.filter(l => l.toLowerCase().includes(logFilter)) : logLines;
el('lg-count').textContent = rows.length;
el('lg-pre').innerHTML = rows.map(lgHighlight).join('\n');
el('lg-pre').scrollTop = el('lg-pre').scrollHeight;
}
async function loadLogs() {
try {
const d = await api('/admin/logs?lines=300');
logLines = d.lines || [];
lgRender();
} catch (e) { if (e.message!=='key 无效或已失效') { el('lg-pre').textContent = e.message; } }
}
function stopLogAuto() { if (logTimer) { clearInterval(logTimer); logTimer = null; } }
function startLogAuto() {
stopLogAuto();
if (el('lg-auto').checked && curTab === 'logs') logTimer = setInterval(loadLogs, 5000);
}
el('lg-refresh').addEventListener('click', loadLogs);
el('lg-search').addEventListener('input', e => { logFilter = e.target.value.trim().toLowerCase(); lgRender(); });
el('lg-auto').addEventListener('change', startLogAuto);
async function uploadFiles() {
const inp = el('upload-file');
const files = inp.files;
if (!files.length) { toast('请先选择文件', 'err'); return; }
const fd = new FormData();
for (const f of files) fd.append('file', f);
el('upload-submit').disabled = true;
try {
const r = await api('/admin/accounts/upload', { method:'POST', body: fd });
let msg = '上传成功 '+(r.count||0)+' 个';
if (r.skipped && r.skipped.length) msg += ',跳过 '+(r.skipped.join(', '));
toast(msg, 'ok');
if (r.errors && r.errors.length) {
const errs = r.errors.map(e=>e.file+': '+e.error).join(';');
toast('部分失败:'+errs, 'err');
}
closeMasks();
inp.value = '';
refresh();
} catch (e) { toast(e.message, 'err'); }
finally { el('upload-submit').disabled = false; }
}
function open(id) { el(id).classList.add('open'); }
function closeMasks() { document.querySelectorAll('.modal-mask').forEach(m=>m.classList.remove('open')); }
function showLogin() { el('page-login').style.display=''; el('page-main').style.display='none'; if (timer) clearInterval(timer); }
function showMain() { el('page-login').style.display='none'; el('page-main').style.display='flex'; }
function startAuto() { const ms=parseInt(el('refresh-rate').value,10); if (timer) clearInterval(timer); if (ms>0) timer=setInterval(refresh, ms); }
// ---- 用量统计 ----
let usageData = null;
const fmtK = n => n >= 1e6 ? (n/1e6).toFixed(2)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n);
async function loadUsage() {
const w = el('usage-window').value;
try {
const d = await api('/admin/usage?window=' + encodeURIComponent(w));
usageData = d;
const t = d.total || {};
el('u-total').textContent = fmtK(t.total||0);
el('u-input').textContent = fmtK(t.input||0);
el('u-output').textContent = fmtK(t.output||0);
el('u-requests').textContent = t.requests||0;
// 分模型表
const tot = t.total || 0;
el('u-tb').innerHTML = (d.by_model||[]).length
? d.by_model.map(m => {
const pct = tot ? Math.round(m.total/tot*100) : 0;
return '<tr><td><code>'+esc(m.model)+'</code></td>'+
'<td class="muted">'+fmtK(m.input)+'</td>'+
'<td class="muted">'+fmtK(m.output)+'</td>'+
'<td class="bal pos">'+fmtK(m.total)+'</td>'+
'<td class="muted">'+m.requests+'</td>'+
'<td><span class="bar-pct" style="width:'+Math.max(pct,2)+'px"></span> <span class="muted">'+pct+'%</span></td></tr>';
}).join('')
: '<tr class="empty-row"><td colspan="6">该时段暂无用量记录</td></tr>';
drawChart();
} catch (e) { if (e.message!=='key 无效或已失效') toast(e.message, 'err'); }
}
function drawChart() {
const cv = el('usage-chart');
if (!cv || !usageData) return;
const dpr = window.devicePixelRatio || 1;
const W = cv.clientWidth, H = 300;
cv.width = W * dpr; cv.height = H * dpr;
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const buckets = usageData.buckets || [];
if (!buckets.length) return;
const pad = { l: 56, r: 16, t: 14, b: 28 };
const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b;
const maxV = Math.max(1, ...buckets.map(b => Math.max(b.input, b.output)));
const css = getComputedStyle(document.documentElement);
const cAccent = css.getPropertyValue('--accent').trim() || '#5a8dff';
const cPurple = css.getPropertyValue('--purple').trim() || '#b088ff';
const cGrid = css.getPropertyValue('--border').trim() || '#2a3140';
const cMuted = css.getPropertyValue('--muted').trim() || '#8b94a7';
// 网格 + y 轴刻度
ctx.font = '11px Consolas, monospace'; ctx.fillStyle = cMuted; ctx.strokeStyle = cGrid; ctx.lineWidth = 1;
const steps = 4;
for (let i = 0; i <= steps; i++) {
const y = pad.t + ch - (ch * i / steps);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.globalAlpha = .5; ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(fmtK(Math.round(maxV * i / steps)), pad.l - 8, y);
}
const xAt = i => pad.l + (buckets.length === 1 ? cw/2 : cw * i / (buckets.length - 1));
const yAt = v => pad.t + ch - (ch * v / maxV);
// x 轴时间标签(首/中/尾)
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
const fmtT = ts => { const d = new Date(ts*1000); const p=n=>String(n).padStart(2,'0');
return usageData.granularity >= 86400 ? (d.getMonth()+1)+'/'+d.getDate() : p(d.getHours())+':'+p(d.getMinutes()); };
[0, Math.floor(buckets.length/2), buckets.length-1].forEach(i => {
if (buckets[i]) ctx.fillText(fmtT(buckets[i].t), xAt(i), H - pad.b + 6);
});
// 折线(先输出后输入,输入在上层)
const line = (key, color) => {
ctx.beginPath();
buckets.forEach((b, i) => { const x = xAt(i), y = yAt(b[key]); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke();
// 面积填充
ctx.lineTo(xAt(buckets.length-1), pad.t + ch); ctx.lineTo(xAt(0), pad.t + ch); ctx.closePath();
ctx.globalAlpha = .08; ctx.fillStyle = color; ctx.fill(); ctx.globalAlpha = 1;
};
line('output', cPurple);
line('input', cAccent);
}
function enter() { showMain(); startAuto(); refresh(); }
if (key) enter(); else showLogin();
el('login-form').addEventListener('submit', async e => {
e.preventDefault();
const k = el('key').value.trim(); if (!k) return;
key = k; el('login-btn').disabled = true;
try { await api('/admin/accounts'); localStorage.setItem(KEY, key); enter(); }
catch (err) {
if (err.message === 'admin disabled') { localStorage.setItem(KEY, key); enter(); }
else toast(err.message==='key 无效或已失效'?'key 无效':err.message, 'err');
} finally { el('login-btn').disabled = false; }
});
el('btn-logout').addEventListener('click', () => { key=''; localStorage.removeItem(KEY); showLogin(); });
el('btn-refresh').addEventListener('click', refresh);
el('btn-reload').addEventListener('click', reload);
el('refresh-rate').addEventListener('change', startAuto);
el('btn-add').addEventListener('click', () => open('add-mask'));
el('btn-upload').addEventListener('click', () => { el('upload-file').value=''; open('upload-mask'); });
el('upload-submit').addEventListener('click', uploadFiles);
el('btn-balance-all').addEventListener('click', () => bulkBalance([], 'btn-balance-all'));
// 标签切换
let curTab = 'accounts';
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
curTab = t.dataset.tab;
document.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x === t));
el('view-accounts').style.display = curTab === 'accounts' ? '' : 'none';
el('view-usage').style.display = curTab === 'usage' ? '' : 'none';
el('view-apikeys').style.display = curTab === 'apikeys' ? '' : 'none';
el('view-logs').style.display = curTab === 'logs' ? '' : 'none';
if (curTab === 'usage') loadUsage();
if (curTab === 'apikeys') loadApiKeys();
if (curTab === 'logs') { loadLogs(); startLogAuto(); }
else stopLogAuto();
}));
el('usage-window').addEventListener('change', loadUsage);
el('usage-refresh').addEventListener('click', loadUsage);
window.addEventListener('resize', () => { if (curTab === 'usage') drawChart(); });
// 状态筛选 + 搜索
el('filter-status').addEventListener('change', e => { filterStatus = e.target.value; render(); });
el('search-box').addEventListener('input', e => { searchTerm = e.target.value.trim(); render(); });
// 全选(仅作用于当前筛选可见的账号)
el('ck-all').addEventListener('change', e => {
const visible = accounts.filter(a => matchStatus(a) && matchSearch(a));
if (e.target.checked) visible.forEach(a=>selected.add(a.name));
else visible.forEach(a=>selected.delete(a.name));
render();
});
// 行内 checkbox + 操作按钮(事件委托)
el('tb').addEventListener('change', e => {
const ck = e.target.closest('.row-ck'); if (!ck) return;
if (ck.checked) selected.add(ck.dataset.name); else selected.delete(ck.dataset.name);
const tr = ck.closest('tr'); if (tr) tr.classList.toggle('sel', ck.checked);
syncSelUI();
});
el('tb').addEventListener('click', e => {
const btn = e.target.closest('button[data-act]'); if (!btn) return;
const n = btn.dataset.name;
switch (btn.dataset.act) {
case 'enable': setEnabled(n, true); break;
case 'disable': setEnabled(n, false); break;
case 'bal': fetchBal(n); break;
case 'detail': loadDetail(n); break;
case 'del': del(n); break;
}
});
// 批量按钮
el('bulk-enable').addEventListener('click', () => bulk('enable'));
el('bulk-disable').addEventListener('click', () => bulk('disable'));
el('bulk-delete').addEventListener('click', () => bulk('delete'));
el('bulk-balance').addEventListener('click', () => bulkBalance([...selected], 'bulk-balance'));
document.querySelectorAll('.close-x,[data-close]').forEach(n=>n.addEventListener('click', closeMasks));
document.querySelectorAll('.modal-mask').forEach(m=>m.addEventListener('click', e=>{ if(e.target===m) closeMasks(); }));
el('add-form').addEventListener('submit', async e => {
e.preventDefault();
let acc; try { acc = JSON.parse(el('add-account').value); }
catch (err) { toast('JSON 解析失败: '+err.message, 'err'); return; }
if (!acc || typeof acc!=='object' || Array.isArray(acc) || !acc.name) { toast('必须提供 name 字段','err'); return; }
el('add-save').disabled = true;
try { await api('/admin/accounts', {method:'POST', body:JSON.stringify(acc)}); toast('已保存 '+acc.name,'ok'); closeMasks(); el('add-account').value=''; refresh(); }
catch (e) { toast(e.message, 'err'); }
finally { el('add-save').disabled = false; }
});
})();
</script>
</body>
</html>