重构项目结构并纳入版本管理

- 源码移至 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>
This commit is contained in:
2026-06-01 22:03:45 +08:00
parent ade410c289
commit 1216ad0145
16 changed files with 1738 additions and 0 deletions

231
src/go/webui.go Normal file
View File

@@ -0,0 +1,231 @@
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>`