Download app/static/admin.html from li2895/anuma2api: direct link, hf CLI and curl.
- Browser
- Download file 47.1 kB
-
https://huggingface.co/spaces/li2895/anuma2api/resolve/main/app/static/admin.html
- Command line
-
hf download hf://spaces/li2895/anuma2api/app/static/admin.html
-
curl -L -o admin.html https://huggingface.co/spaces/li2895/anuma2api/resolve/main/app/static/admin.html
47.1 kB
| <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 <key></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/<name>.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 => | |
| ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> |