| <!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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| </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 = {}; |
| let filterStatus = 'all'; |
| 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; } |
| } |
| |
| |
| 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'; |
| |
| 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); |
| |
| 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(); |
| }); |
| |
| 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> |