- 源码移至 src/go/,编译产物输出到 build/(不入库) - 新增 .gitignore:排除 data/、build/、*.exe、*.log、说明.md - 新增 build.ps1:从 src/go 编译到 build/relay.exe - 移除 说明.md 的版本跟踪(含上游密钥,仅保留在本地) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
9.9 KiB
Go
232 lines
9.9 KiB
Go
package main
|
||
|
||
// dashboardHTML 是只读管理界面的单页前端(原生 JS,无外部依赖)。
|
||
// 通过 /api/stats、/api/records、/api/records/{id} 拉数据。
|
||
const dashboardHTML = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AI 通讯留存 - 管理界面</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0d1117; --panel: #161b22; --border: #30363d;
|
||
--fg: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
||
--ok: #3fb950; --err: #f85149; --warn: #d29922;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: var(--bg); color: var(--fg);
|
||
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||
header { padding: 16px 24px; border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; gap: 12px; }
|
||
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||
header .sub { color: var(--muted); font-size: 12px; }
|
||
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 12px; margin-bottom: 24px; }
|
||
.card { background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 16px; }
|
||
.card .label { color: var(--muted); font-size: 12px; }
|
||
.card .value { font-size: 24px; font-weight: 600; margin-top: 4px; }
|
||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||
input, select, button { background: var(--panel); color: var(--fg);
|
||
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font-size: 13px; }
|
||
button { cursor: pointer; }
|
||
button:hover { border-color: var(--accent); }
|
||
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
||
border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border);
|
||
font-size: 13px; white-space: nowrap; }
|
||
th { color: var(--muted); font-weight: 500; background: #1c2128; }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr.row:hover { background: #1c2128; cursor: pointer; }
|
||
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
|
||
.s-ok { color: var(--ok); } .s-err { color: var(--err); }
|
||
.tag { background: #1f6feb33; color: var(--accent); padding: 1px 6px;
|
||
border-radius: 4px; font-size: 11px; }
|
||
.muted { color: var(--muted); }
|
||
.pager { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
||
/* 详情抽屉 */
|
||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||
display: none; z-index: 10; }
|
||
.overlay.show { display: block; }
|
||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw);
|
||
background: var(--panel); border-left: 1px solid var(--border);
|
||
overflow-y: auto; padding: 20px; z-index: 11; transform: translateX(100%);
|
||
transition: transform .2s; }
|
||
.drawer.show { transform: translateX(0); }
|
||
.drawer h2 { font-size: 15px; margin: 0 0 16px; }
|
||
.drawer .close { position: absolute; top: 16px; right: 20px; }
|
||
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; margin-bottom: 16px; }
|
||
.kv .k { color: var(--muted); }
|
||
pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||
padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||
font: 12px/1.5 "Consolas", "Courier New", monospace; max-height: 300px; }
|
||
.sec-title { color: var(--muted); font-size: 12px; margin: 16px 0 6px;
|
||
text-transform: uppercase; letter-spacing: .5px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>AI 通讯留存</h1>
|
||
<span class="sub">全量审计 · 只读管理界面</span>
|
||
<span class="sub" id="refreshInfo" style="margin-left:auto"></span>
|
||
<button onclick="loadAll()">刷新</button>
|
||
</header>
|
||
<main>
|
||
<div class="cards" id="cards"></div>
|
||
|
||
<div class="toolbar">
|
||
<input id="fModel" placeholder="按模型过滤 (精确)" />
|
||
<select id="fFormat">
|
||
<option value="">全部格式</option>
|
||
<option value="openai">openai</option>
|
||
<option value="claude">claude</option>
|
||
<option value="ollama">ollama</option>
|
||
<option value="unknown">unknown</option>
|
||
</select>
|
||
<input id="fStatus" placeholder="状态码 (如 200)" style="width:120px" />
|
||
<button onclick="applyFilter()">查询</button>
|
||
<button onclick="clearFilter()">清空</button>
|
||
<span class="muted" id="totalInfo"></span>
|
||
</div>
|
||
|
||
<table>
|
||
<thead><tr>
|
||
<th>#</th><th>时间</th><th>客户端</th><th>格式</th><th>模型</th>
|
||
<th>路径</th><th>状态</th><th>输入</th><th>输出</th><th>合计</th>
|
||
<th>流式</th><th>耗时</th>
|
||
</tr></thead>
|
||
<tbody id="rows"></tbody>
|
||
</table>
|
||
|
||
<div class="pager">
|
||
<button id="prev" onclick="prevPage()">上一页</button>
|
||
<span class="muted" id="pageInfo"></span>
|
||
<button id="next" onclick="nextPage()">下一页</button>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="overlay" id="overlay" onclick="closeDrawer()"></div>
|
||
<div class="drawer" id="drawer">
|
||
<button class="close" onclick="closeDrawer()">关闭</button>
|
||
<div id="detail"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const PAGE = 50;
|
||
let offset = 0, total = 0;
|
||
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
function fmtTime(ms) {
|
||
const d = new Date(ms);
|
||
const p = n => String(n).padStart(2, "0");
|
||
return d.getFullYear() + "-" + p(d.getMonth()+1) + "-" + p(d.getDate()) +
|
||
" " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
||
}
|
||
function statusClass(s) { return (s >= 200 && s < 400) ? "s-ok" : "s-err"; }
|
||
|
||
async function loadStats() {
|
||
const r = await fetch("api/stats");
|
||
const s = await r.json();
|
||
const cards = [
|
||
["总请求数", s.total_requests],
|
||
["成功", s.success_count],
|
||
["失败", s.error_count],
|
||
["Token 合计", (s.total_tokens || 0).toLocaleString()],
|
||
["模型种类", (s.by_model || []).length],
|
||
];
|
||
document.getElementById("cards").innerHTML = cards.map(c =>
|
||
'<div class="card"><div class="label">' + esc(c[0]) +
|
||
'</div><div class="value">' + esc(c[1]) + '</div></div>').join("");
|
||
}
|
||
|
||
async function loadRecords() {
|
||
const params = new URLSearchParams();
|
||
params.set("limit", PAGE);
|
||
params.set("offset", offset);
|
||
const m = document.getElementById("fModel").value.trim();
|
||
const f = document.getElementById("fFormat").value;
|
||
const st = document.getElementById("fStatus").value.trim();
|
||
if (m) params.set("model", m);
|
||
if (f) params.set("format", f);
|
||
if (st) params.set("status", st);
|
||
|
||
const r = await fetch("api/records?" + params.toString());
|
||
const data = await r.json();
|
||
total = data.total || 0;
|
||
const rows = data.records || [];
|
||
document.getElementById("rows").innerHTML = rows.map(rec =>
|
||
'<tr class="row" onclick="openDetail(' + rec.id + ')">' +
|
||
'<td>' + rec.id + '</td>' +
|
||
'<td>' + esc(fmtTime(rec.ts)) + '</td>' +
|
||
'<td>' + esc(rec.client_ip) + '</td>' +
|
||
'<td><span class="tag">' + esc(rec.api_format) + '</span></td>' +
|
||
'<td>' + esc(rec.model || "-") + '</td>' +
|
||
'<td class="muted">' + esc(rec.endpoint) + '</td>' +
|
||
'<td class="' + statusClass(rec.status) + '">' + rec.status + '</td>' +
|
||
'<td>' + rec.input_tokens + '</td>' +
|
||
'<td>' + rec.output_tokens + '</td>' +
|
||
'<td>' + rec.total_tokens + '</td>' +
|
||
'<td>' + (rec.is_stream ? "是" : "否") + '</td>' +
|
||
'<td class="muted">' + rec.duration_ms + 'ms</td>' +
|
||
'</tr>').join("") ||
|
||
'<tr><td colspan="12" class="muted" style="text-align:center;padding:24px">暂无记录</td></tr>';
|
||
|
||
document.getElementById("totalInfo").textContent = "共 " + total + " 条";
|
||
const from = total === 0 ? 0 : offset + 1;
|
||
const to = Math.min(offset + PAGE, total);
|
||
document.getElementById("pageInfo").textContent = from + "-" + to + " / " + total;
|
||
document.getElementById("prev").disabled = offset <= 0;
|
||
document.getElementById("next").disabled = offset + PAGE >= total;
|
||
}
|
||
|
||
async function openDetail(id) {
|
||
const r = await fetch("api/records/" + id);
|
||
if (!r.ok) { alert("读取详情失败: " + await r.text()); return; }
|
||
const b = await r.json();
|
||
const hdr = h => Object.entries(h || {}).map(e =>
|
||
esc(e[0]) + ": " + esc(e[1])).join("\n");
|
||
document.getElementById("detail").innerHTML =
|
||
'<h2>记录 #' + b.id + '</h2>' +
|
||
'<div class="kv">' +
|
||
'<div class="k">时间</div><div>' + esc(b.timestamp) + '</div>' +
|
||
'<div class="k">客户端</div><div>' + esc(b.client_ip) + '</div>' +
|
||
'<div class="k">方法/路径</div><div>' + esc(b.method) + ' ' + esc(b.path) + '</div>' +
|
||
'<div class="k">状态码</div><div class="' + statusClass(b.status) + '">' + b.status + '</div>' +
|
||
'<div class="k">耗时</div><div>' + b.duration_ms + 'ms</div>' +
|
||
'</div>' +
|
||
'<div class="sec-title">请求头</div><pre>' + esc(hdr(b.req_header)) + '</pre>' +
|
||
'<div class="sec-title">请求体</div><pre>' + esc(b.req_body) + '</pre>' +
|
||
'<div class="sec-title">响应头</div><pre>' + esc(hdr(b.resp_header)) + '</pre>' +
|
||
'<div class="sec-title">响应体</div><pre>' + esc(b.resp_body) + '</pre>';
|
||
document.getElementById("overlay").classList.add("show");
|
||
document.getElementById("drawer").classList.add("show");
|
||
}
|
||
function closeDrawer() {
|
||
document.getElementById("overlay").classList.remove("show");
|
||
document.getElementById("drawer").classList.remove("show");
|
||
}
|
||
|
||
function applyFilter() { offset = 0; loadRecords(); }
|
||
function clearFilter() {
|
||
document.getElementById("fModel").value = "";
|
||
document.getElementById("fFormat").value = "";
|
||
document.getElementById("fStatus").value = "";
|
||
offset = 0; loadRecords();
|
||
}
|
||
function prevPage() { offset = Math.max(0, offset - PAGE); loadRecords(); }
|
||
function nextPage() { if (offset + PAGE < total) { offset += PAGE; loadRecords(); } }
|
||
|
||
function loadAll() {
|
||
loadStats(); loadRecords();
|
||
document.getElementById("refreshInfo").textContent = "更新于 " + fmtTime(Date.now());
|
||
}
|
||
loadAll();
|
||
</script>
|
||
</body>
|
||
</html>`
|