build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory ring buffer. Each entry captures request headers (Authorization masked), request body, response status, and response body — all up to 1 MB. - recorder.go: RecentRecorder ring buffer, recordingResponseWriter, withRecording middleware, getRecent handler - server.go: add recorder to Server, wrap /v1/ routes, add /api/recent - admin.html: new 最近请求 tab with lazy-load and expandable cards - recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
609 lines
35 KiB
HTML
609 lines
35 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>湛卢代理管理后台</title>
|
||
<style>
|
||
:root{
|
||
color-scheme:light;
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
||
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
|
||
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
||
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
|
||
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
|
||
--radius:16px; --radius-sm:10px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
||
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
|
||
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||
.brand{display:flex;align-items:center;gap:10px}
|
||
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
|
||
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
||
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
|
||
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
|
||
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.btn-primary{background:var(--accent);color:#fff}
|
||
.btn-primary:hover{background:var(--accent-hover)}
|
||
.btn-primary:active{transform:translateY(1px)}
|
||
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
|
||
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
||
.btn-ghost:active{transform:translateY(1px)}
|
||
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
|
||
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
|
||
.tab:hover{color:var(--ink)}
|
||
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
|
||
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
||
.tabpanel{display:none;min-width:0}
|
||
.tabpanel.active{display:block;min-width:0}
|
||
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
|
||
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
||
.panel-head h2{margin:0}
|
||
.seg{display:inline-flex;border:1px solid var(--border-strong);border-radius:999px;padding:2px;background:var(--bg)}
|
||
.seg-btn{border:0;background:none;font:inherit;font-weight:600;font-size:12.5px;color:var(--body);padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease}
|
||
.seg-btn:hover{color:var(--ink)}
|
||
.seg-btn[aria-current="true"]{background:var(--accent);color:#fff}
|
||
.seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
|
||
.panel.disabled{background:#eef0f3}
|
||
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
|
||
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
|
||
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
|
||
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
tbody tr:last-child td{border-bottom:0}
|
||
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
|
||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
|
||
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
|
||
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
|
||
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
|
||
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
|
||
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
|
||
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
|
||
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
|
||
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
|
||
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
||
.muted{color:var(--muted);font-size:13px;line-height:1.6}
|
||
.scroll{overflow-x:auto;min-width:0}
|
||
.empty{color:var(--muted);font-size:13px;padding:8px 0}
|
||
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
|
||
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
|
||
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
||
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
.page-btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
|
||
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
|
||
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
|
||
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
|
||
form{display:grid;gap:18px}
|
||
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
|
||
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
|
||
input::placeholder{color:var(--muted)}
|
||
input:hover{border-color:#bdc2cc}
|
||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
|
||
.btn-primary.full{width:100%}
|
||
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
|
||
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
|
||
.status[data-state="ok"]{color:var(--ok)}
|
||
.status[data-state="err"]{color:var(--err)}
|
||
.status[data-state="busy"]{color:var(--accent)}
|
||
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
|
||
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
||
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
|
||
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
|
||
.recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)}
|
||
.recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
|
||
.recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||
.recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px}
|
||
.recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||
.recent-card details{margin-top:6px}
|
||
.recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none}
|
||
.recent-card summary:hover{color:var(--accent)}
|
||
.recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header class="top">
|
||
<div>
|
||
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
|
||
<h1>湛卢代理管理</h1>
|
||
</div>
|
||
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
|
||
</header>
|
||
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
||
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
|
||
<button class="tab" role="tab" data-tab="recent" aria-selected="false">最近请求</button>
|
||
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
||
</div>
|
||
|
||
<section class="tabpanel active" data-tab="stats" role="tabpanel">
|
||
<div class="sub-actions">
|
||
<button class="btn btn-ghost" id="refresh">刷新</button>
|
||
<button class="btn btn-primary" id="reset">重置统计</button>
|
||
</div>
|
||
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
|
||
<div class="panel">
|
||
<h2>总览</h2>
|
||
<div class="grid4">
|
||
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
|
||
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
|
||
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
|
||
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
|
||
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="panel" style="margin-top:20px">
|
||
<div class="panel-head">
|
||
<h2>按模型</h2>
|
||
<div class="seg" id="model-range" role="group" aria-label="按模型统计时间范围">
|
||
<button class="seg-btn" type="button" data-range="1d">1 天</button>
|
||
<button class="seg-btn" type="button" data-range="7d">7 天</button>
|
||
<button class="seg-btn" type="button" data-range="all" aria-current="true">全部</button>
|
||
</div>
|
||
</div>
|
||
<div class="scroll" id="model-scroll"><table>
|
||
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
|
||
<tbody id="model-tbody">
|
||
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
||
</tbody>
|
||
</table></div>
|
||
<p class="empty" id="model-empty" style="display:none">暂无数据</p>
|
||
</div>
|
||
<div class="panel" style="margin-top:20px">
|
||
<h2>按日</h2>
|
||
{{if .Stats.Daily}}
|
||
<div id="daily">
|
||
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
|
||
</div>
|
||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
||
</div>
|
||
<div class="panel" style="margin-top:20px">
|
||
<h2>最近请求</h2>
|
||
{{if .Stats.Recent}}
|
||
<div class="scroll"><table id="recent">
|
||
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
|
||
<tbody>
|
||
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
|
||
</tbody>
|
||
</table></div>
|
||
<div class="pager" id="recent-pager"></div>
|
||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="tabpanel" data-tab="models" role="tabpanel">
|
||
<div class="sub-actions">
|
||
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
|
||
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>可用模型</h2>
|
||
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
|
||
<div class="scroll">
|
||
<table id="models-table">
|
||
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="tabpanel" data-tab="recent" role="tabpanel">
|
||
<div class="sub-actions">
|
||
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>最近 API 请求</h2>
|
||
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
|
||
<div id="recent-list"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="tabpanel" data-tab="login" role="tabpanel">
|
||
<div class="login-card">
|
||
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
||
<form id="phone-form">
|
||
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
|
||
<label>验证码
|
||
<div class="row">
|
||
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
|
||
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
|
||
</div>
|
||
</label>
|
||
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
|
||
</form>
|
||
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
var tabs = document.querySelectorAll('.tab');
|
||
var panels = document.querySelectorAll('.tabpanel');
|
||
function activate(name) {
|
||
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
|
||
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
|
||
}
|
||
tabs.forEach(function (tab) {
|
||
tab.addEventListener('click', function () {
|
||
var name = tab.dataset.tab;
|
||
activate(name);
|
||
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
||
});
|
||
});
|
||
var hash = location.hash.replace('#', '');
|
||
if (hash === 'models') activate('models');
|
||
else if (hash === 'recent') activate('recent');
|
||
else if (hash === 'login') activate('login');
|
||
|
||
var rows = document.querySelectorAll('#daily .bar');
|
||
var max = 1;
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
|
||
if (t > max) max = t;
|
||
}
|
||
for (var j = 0; j < rows.length; j++) {
|
||
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
|
||
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
|
||
}
|
||
var refresh = document.getElementById('refresh');
|
||
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
|
||
var reset = document.getElementById('reset');
|
||
if (reset) reset.addEventListener('click', function () {
|
||
if (!confirm('确定清空所有统计数据?')) return;
|
||
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
|
||
});
|
||
|
||
// by-model time-range filter (1d / 7d / all) — fetches /api/stats with a
|
||
// since cutoff and re-renders only the per-model table client-side.
|
||
(function () {
|
||
var seg = document.getElementById('model-range');
|
||
var tbody = document.getElementById('model-tbody');
|
||
var scroll = document.getElementById('model-scroll');
|
||
var empty = document.getElementById('model-empty');
|
||
if (!seg || !tbody) return;
|
||
function trimZero(s) { return s.indexOf('.') >= 0 ? s.replace(/\.0$/, '') : s; }
|
||
function human(v) {
|
||
var f = Number(v) || 0;
|
||
if (f < 1000) return String(Math.round(f));
|
||
if (f < 1e6) return trimZero((f / 1e3).toFixed(1)) + 'K';
|
||
if (f < 1e9) return trimZero((f / 1e6).toFixed(1)) + 'M';
|
||
return trimZero((f / 1e9).toFixed(1)) + 'B';
|
||
}
|
||
function rate(cached, prompt) {
|
||
if (prompt <= 0) return '0%';
|
||
return (cached / prompt * 100).toFixed(1) + '%';
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
function showEmpty(yes) {
|
||
scroll.style.display = yes ? 'none' : '';
|
||
empty.style.display = yes ? '' : 'none';
|
||
}
|
||
function render(rows) {
|
||
if (!rows || rows.length === 0) { tbody.innerHTML = ''; showEmpty(true); return; }
|
||
showEmpty(false);
|
||
var html = '';
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var r = rows[i];
|
||
html += '<tr><td>' + escapeHtml(r.model) + '</td>' +
|
||
'<td class="num">' + (r.requests || 0) + '</td>' +
|
||
'<td class="num">' + human(r.prompt_tokens) + '</td>' +
|
||
'<td class="num">' + human(r.completion_tokens) + '</td>' +
|
||
'<td class="num">' + human(r.total_tokens) + '</td>' +
|
||
'<td class="num">' + human(r.cached_tokens) + '</td>' +
|
||
'<td class="num">' + rate(r.cached_tokens, r.prompt_tokens) + '</td></tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
// initial empty check (server-rendered "all" may have no rows)
|
||
if (!tbody.querySelector('tr')) showEmpty(true);
|
||
function sinceParam(range) {
|
||
if (range === 'all') return '';
|
||
var days = range === '1d' ? 1 : 7;
|
||
return 'since=' + encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString());
|
||
}
|
||
seg.addEventListener('click', async function (ev) {
|
||
var btn = ev.target.closest('.seg-btn');
|
||
if (!btn) return;
|
||
seg.querySelectorAll('.seg-btn').forEach(function (b) {
|
||
b.setAttribute('aria-current', b === btn ? 'true' : 'false');
|
||
});
|
||
var range = btn.dataset.range;
|
||
var param = sinceParam(range);
|
||
var url = '/api/stats' + (param ? '?' + param : '');
|
||
showEmpty(false);
|
||
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载中…</td></tr>';
|
||
try {
|
||
var res = await fetch(url);
|
||
var data = await res.json();
|
||
if (!data.stats) { render([]); return; }
|
||
render(data.stats.per_model || []);
|
||
} catch (e) {
|
||
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载失败</td></tr>';
|
||
}
|
||
});
|
||
})();
|
||
|
||
// paginate the recent-requests table (data is already rendered server-side)
|
||
(function () {
|
||
var tbody = document.querySelector('#recent tbody');
|
||
var pager = document.getElementById('recent-pager');
|
||
if (!tbody || !pager) return;
|
||
var rows = tbody.querySelectorAll('tr');
|
||
if (rows.length === 0) { pager.style.display = 'none'; return; }
|
||
var pageSize = 10;
|
||
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||
var page = 1;
|
||
function pageList(c, t) {
|
||
var p = [];
|
||
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
|
||
p.push(1);
|
||
if (c > 3) p.push('…');
|
||
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
|
||
for (var m = s; m <= e; m++) p.push(m);
|
||
if (c < t - 2) p.push('…');
|
||
p.push(t);
|
||
return p;
|
||
}
|
||
function render() {
|
||
var start = (page - 1) * pageSize;
|
||
for (var i = 0; i < rows.length; i++) {
|
||
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
|
||
}
|
||
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '>‹</button>';
|
||
var list = pageList(page, totalPages);
|
||
for (var n = 0; n < list.length; n++) {
|
||
var item = list[n];
|
||
if (item === '…') html += '<span class="page-btn dots">…</span>';
|
||
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
|
||
}
|
||
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
|
||
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
|
||
pager.innerHTML = html;
|
||
}
|
||
pager.addEventListener('click', function (ev) {
|
||
var btn = ev.target.closest('.page-btn');
|
||
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
|
||
if (btn.dataset.act === 'prev' && page > 1) page--;
|
||
else if (btn.dataset.act === 'next' && page < totalPages) page++;
|
||
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
|
||
render();
|
||
});
|
||
render();
|
||
})();
|
||
|
||
var logout = document.getElementById('logout-button');
|
||
if (logout) logout.addEventListener('click', async function () {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/login';
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var statusEl = document.getElementById('status');
|
||
var form = document.getElementById('phone-form');
|
||
var codeButton = document.getElementById('code-button');
|
||
var secret = '';
|
||
var countdown = 0;
|
||
var countdownTimer = null;
|
||
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
||
function startCountdown() {
|
||
countdown = 60;
|
||
codeButton.disabled = true;
|
||
countdownTimer && clearInterval(countdownTimer);
|
||
countdownTimer = setInterval(function () {
|
||
if (countdown <= 0) {
|
||
clearInterval(countdownTimer);
|
||
codeButton.disabled = false;
|
||
codeButton.textContent = '获取验证码';
|
||
return;
|
||
}
|
||
codeButton.textContent = countdown + 's';
|
||
countdown--;
|
||
}, 1000);
|
||
}
|
||
if (form) {
|
||
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
|
||
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
||
statusEl.dataset.state = data.configured ? 'ok' : '';
|
||
});
|
||
}
|
||
if (codeButton) codeButton.addEventListener('click', async function () {
|
||
var telephone = form.telephone.value.trim();
|
||
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
|
||
codeButton.disabled = true;
|
||
setStatus('正在发送验证码...', 'busy');
|
||
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
|
||
var data = await res.json();
|
||
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
|
||
secret = data.secret;
|
||
setStatus('验证码已发送', 'ok');
|
||
startCountdown();
|
||
});
|
||
if (form) form.addEventListener('submit', async function (event) {
|
||
event.preventDefault();
|
||
var telephone = form.telephone.value.trim();
|
||
var code = form.code.value.trim();
|
||
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
|
||
setStatus('正在登录并保存凭据...', 'busy');
|
||
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
|
||
var data = await res.json();
|
||
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
|
||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var refreshBtn = document.getElementById('models-refresh');
|
||
var testAllBtn = document.getElementById('models-test-all');
|
||
var statusEl = document.getElementById('models-status');
|
||
var tbody = document.querySelector('#models-table tbody');
|
||
if (!refreshBtn || !tbody) return;
|
||
var loaded = false;
|
||
|
||
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, function (c) {
|
||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||
});
|
||
}
|
||
function renderRow(model) {
|
||
var tr = document.createElement('tr');
|
||
tr.dataset.model = model;
|
||
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
|
||
'<td class="m-status"><span class="muted">未测试</span></td>' +
|
||
'<td class="num m-ttft">—</td>' +
|
||
'<td class="num m-total">—</td>' +
|
||
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
|
||
return tr;
|
||
}
|
||
async function loadModels() {
|
||
setStatus('正在获取模型列表...', 'busy');
|
||
refreshBtn.disabled = true;
|
||
try {
|
||
var res = await fetch('/api/models');
|
||
var data = await res.json();
|
||
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
|
||
var models = data.models || [];
|
||
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
|
||
tbody.innerHTML = '';
|
||
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
|
||
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
|
||
} catch (e) {
|
||
setStatus('获取模型列表失败:' + e.message, 'err');
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
}
|
||
}
|
||
async function testModel(model, row) {
|
||
var statusCell = row.querySelector('.m-status');
|
||
var ttftCell = row.querySelector('.m-ttft');
|
||
var totalCell = row.querySelector('.m-total');
|
||
var btn = row.querySelector('.m-test');
|
||
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
|
||
ttftCell.textContent = '—';
|
||
totalCell.textContent = '—';
|
||
btn.disabled = true;
|
||
try {
|
||
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
|
||
var data = await res.json();
|
||
if (data.available) {
|
||
statusCell.innerHTML = '<span class="badge ok">可用</span>';
|
||
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
|
||
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
|
||
} else {
|
||
statusCell.innerHTML = '<span class="badge err">不可用</span>';
|
||
statusCell.title = data.error || '';
|
||
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
|
||
}
|
||
} catch (e) {
|
||
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
|
||
statusCell.title = e.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
refreshBtn.addEventListener('click', loadModels);
|
||
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
|
||
var rows = tbody.querySelectorAll('tr');
|
||
for (var i = 0; i < rows.length; i++) {
|
||
await testModel(rows[i].dataset.model, rows[i]);
|
||
}
|
||
});
|
||
tbody.addEventListener('click', function (ev) {
|
||
var btn = ev.target.closest('.m-test');
|
||
if (!btn) return;
|
||
var row = btn.closest('tr');
|
||
if (row && row.dataset.model) testModel(row.dataset.model, row);
|
||
});
|
||
// lazy-load the model list the first time the tab becomes active
|
||
function loadIfActive() {
|
||
if (loaded) return;
|
||
var panel = document.querySelector('.tabpanel[data-tab="models"]');
|
||
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
|
||
}
|
||
var modelsTab = document.querySelector('.tab[data-tab="models"]');
|
||
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
|
||
loadIfActive();
|
||
})();
|
||
|
||
(function () {
|
||
var recentList = document.getElementById('recent-list');
|
||
if (!recentList) return;
|
||
var loaded = false;
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
function formatTime(ts) {
|
||
var d = new Date(ts);
|
||
return d.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
async function loadRecent() {
|
||
recentList.innerHTML = '<p class="muted">加载中…</p>';
|
||
try {
|
||
var res = await fetch('/api/recent');
|
||
var data = await res.json();
|
||
if (!data.ok || !data.requests || data.requests.length === 0) {
|
||
recentList.innerHTML = '<p class="empty">暂无数据</p>';
|
||
return;
|
||
}
|
||
var html = '';
|
||
for (var i = 0; i < data.requests.length; i++) {
|
||
var r = data.requests[i];
|
||
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
|
||
var headers = r.request_headers || {};
|
||
var headerStr = Object.keys(headers).map(function (k) {
|
||
return k + ': ' + headers[k];
|
||
}).join('\n');
|
||
html += '<div class="recent-card">' +
|
||
'<div class="recent-head">' +
|
||
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
|
||
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
|
||
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
|
||
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
|
||
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
|
||
'</div>' +
|
||
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
|
||
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
|
||
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
|
||
'</div>';
|
||
}
|
||
recentList.innerHTML = html;
|
||
} catch (e) {
|
||
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
|
||
}
|
||
}
|
||
var recentTab = document.querySelector('.tab[data-tab="recent"]');
|
||
if (recentTab) recentTab.addEventListener('click', function () {
|
||
if (!loaded) { loaded = true; loadRecent(); }
|
||
});
|
||
var refreshBtn = document.getElementById('recent-refresh');
|
||
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
|
||
// auto-load if the tab is active on page load
|
||
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
|
||
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |