Files
goaiapi/src/go/webui.go
XiuChengWu 1216ad0145 重构项目结构并纳入版本管理
- 源码移至 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>
2026-06-01 22:03:45 +08:00

232 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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>`