- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
371 lines
20 KiB
HTML
371 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>群聊人设卡 · 管理后台</title>
|
||
<style>
|
||
:root { --bg:#1a1d24; --panel:#232833; --border:#333a47; --text:#dde2ea; --muted:#8a94a6;
|
||
--accent:#5b8cff; --danger:#ff5c5c; --ok:#3ecf8e; --sidebar:#1e222b; }
|
||
* { box-sizing:border-box; margin:0; padding:0; }
|
||
body { background:var(--bg); color:var(--text); font:14px/1.6 "Microsoft YaHei",sans-serif; }
|
||
h1 { font-size:20px; }
|
||
h2 { font-size:16px; margin:16px 0 10px; }
|
||
.card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
|
||
table { width:100%; border-collapse:collapse; }
|
||
th,td { padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
|
||
th { color:var(--muted); font-weight:normal; font-size:12px; }
|
||
tr:hover td { background:rgba(255,255,255,.03); }
|
||
button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:6px 14px; cursor:pointer; font-size:13px; }
|
||
button:hover { opacity:.85; }
|
||
button.danger { background:var(--danger); }
|
||
button.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||
input,select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font-size:13px; }
|
||
.switch { position:relative; width:42px; height:22px; display:inline-block; cursor:pointer; }
|
||
.switch input { display:none; }
|
||
.switch .track { position:absolute; inset:0; background:var(--border); border-radius:11px; transition:.2s; }
|
||
.switch .track::after { content:""; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:3px; left:3px; transition:.2s; }
|
||
.switch input:checked + .track { background:var(--ok); }
|
||
.switch input:checked + .track::after { left:23px; }
|
||
.muted { color:var(--muted); font-size:12px; }
|
||
.tabs { display:flex; gap:6px; margin-bottom:12px; }
|
||
.tabs button.active { background:var(--accent); }
|
||
pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:12px; white-space:pre-wrap; word-break:break-all; font-size:12px; max-height:400px; overflow:auto; }
|
||
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
||
.hidden { display:none; }
|
||
.msg { margin:8px 0; font-size:13px; }
|
||
.msg.err { color:var(--danger); }
|
||
.msg.ok { color:var(--ok); }
|
||
/* 登录 */
|
||
.login-wrap { max-width:320px; margin:80px auto; }
|
||
.login-wrap .card { padding:28px; }
|
||
.login-wrap input { width:100%; margin-bottom:12px; padding:9px; }
|
||
.login-wrap button { width:100%; padding:9px; }
|
||
/* 侧栏 + 主体 */
|
||
#mainView { display:flex; min-height:100vh; }
|
||
.sidebar { width:200px; flex-shrink:0; background:var(--sidebar); border-right:1px solid var(--border);
|
||
display:flex; flex-direction:column; position:sticky; top:0; height:100vh; }
|
||
.brand { padding:20px 18px 14px; font-size:16px; font-weight:bold; border-bottom:1px solid var(--border); }
|
||
.nav { flex:1; padding:10px 0; }
|
||
.nav a { display:block; padding:10px 18px; color:var(--muted); text-decoration:none; font-size:14px; border-left:3px solid transparent; }
|
||
.nav a:hover { color:var(--text); background:rgba(255,255,255,.03); }
|
||
.nav a.active { color:var(--text); background:rgba(91,140,255,.12); border-left-color:var(--accent); }
|
||
.sidebar-footer { padding:14px 18px; border-top:1px solid var(--border); }
|
||
.sidebar-footer button { width:100%; background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||
.sidebar-footer button:hover { color:var(--danger); border-color:var(--danger); }
|
||
.content { flex:1; padding:24px; min-width:0; }
|
||
.page-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
||
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loginView" class="login-wrap hidden">
|
||
<div class="card">
|
||
<h1 style="text-align:center">群聊人设卡</h1>
|
||
<input id="username" placeholder="用户名">
|
||
<input id="password" type="password" placeholder="密码">
|
||
<button onclick="login()">登 录</button>
|
||
<div id="loginMsg" class="msg"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="mainView" class="hidden">
|
||
<aside class="sidebar">
|
||
<div class="brand">群聊人设卡</div>
|
||
<nav class="nav">
|
||
<a href="#/groups">群管理</a>
|
||
<a href="#/data">数据浏览</a>
|
||
<a href="#/settings">设置</a>
|
||
</nav>
|
||
<div class="sidebar-footer"><button onclick="logout()">退出登录</button></div>
|
||
</aside>
|
||
<main class="content" id="content"></main>
|
||
</div>
|
||
|
||
<script>
|
||
const API = "/galgame_card/api";
|
||
let TOKEN = localStorage.getItem("gc_token") || "";
|
||
|
||
/* ---------- 基础 ---------- */
|
||
async function api(path, options = {}) {
|
||
const res = await fetch(API + path, {
|
||
...options,
|
||
headers: { "Content-Type": "application/json", "Authorization": TOKEN, ...(options.headers || {}) },
|
||
});
|
||
const data = await res.json();
|
||
if (data.status !== 0) {
|
||
if (data.status === 400) showLogin();
|
||
throw new Error(data.msg || "请求失败");
|
||
}
|
||
return data.data;
|
||
}
|
||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); }
|
||
function pageHead(title, extra = "") {
|
||
return `<div class="page-head"><h1>${esc(title)}</h1><div>${extra}</div></div>`;
|
||
}
|
||
function showLogin() {
|
||
document.getElementById("loginView").classList.remove("hidden");
|
||
document.getElementById("mainView").classList.add("hidden");
|
||
}
|
||
function showMain() {
|
||
document.getElementById("loginView").classList.add("hidden");
|
||
document.getElementById("mainView").classList.remove("hidden");
|
||
}
|
||
async function login() {
|
||
const msg = document.getElementById("loginMsg");
|
||
try {
|
||
const data = await api("/login", { method: "POST", body: JSON.stringify({
|
||
username: document.getElementById("username").value.trim(),
|
||
password: document.getElementById("password").value }) });
|
||
TOKEN = data.token; localStorage.setItem("gc_token", TOKEN);
|
||
msg.className = "msg ok"; msg.textContent = "登录成功";
|
||
showMain(); location.hash = "#/groups"; route();
|
||
} catch (e) { msg.className = "msg err"; msg.textContent = e.message; }
|
||
}
|
||
function logout() { localStorage.removeItem("gc_token"); TOKEN = ""; showLogin(); }
|
||
|
||
/* ---------- 路由:侧栏 + 主体 ---------- */
|
||
const routes = { "#/groups": renderGroupList, "#/data": renderDataBrowser, "#/settings": renderSettings };
|
||
function route() {
|
||
const hash = location.hash || "#/groups";
|
||
document.querySelectorAll(".nav a").forEach(a => a.classList.toggle("active", a.getAttribute("href") === hash));
|
||
const content = document.getElementById("content");
|
||
const m = hash.match(/^#\/groups\/(\d+)/);
|
||
if (m) { renderGroupDetail(Number(m[1])); return; }
|
||
(routes[hash] || renderGroupList)(content);
|
||
}
|
||
|
||
/* ---------- 群管理 ---------- */
|
||
async function renderGroupList(content) {
|
||
content.innerHTML = pageHead("群管理", '<button class="ghost" onclick="route()">刷新</button>') +
|
||
`<div class="card"><h2>群采集开关</h2>
|
||
<table><thead><tr><th>群</th><th>开关</th><th>参与者</th><th>语料</th><th>印象</th><th>画像</th><th></th></tr></thead>
|
||
<tbody id="groupRows"></tbody></table><div id="listMsg" class="msg"></div></div>`;
|
||
const tbody = document.getElementById("groupRows");
|
||
document.getElementById("listMsg").textContent = "加载中…";
|
||
try {
|
||
const groups = await api("/groups");
|
||
tbody.innerHTML = groups.map(g => `<tr>
|
||
<td><b>${esc(g.group_name)}</b><br><span class="muted">${g.group_id}</span></td>
|
||
<td><label class="switch"><input type="checkbox" ${g.enabled ? "checked" : ""} onchange="toggleGroup(${g.group_id}, this.checked)"><span class="track"></span></label></td>
|
||
<td>${g.participants}</td><td>${g.corpus_count}</td><td>${g.impression_count}</td><td>${g.summary_count}</td>
|
||
<td><a href="#/groups/${g.group_id}"><button>管理</button></a></td></tr>`).join("");
|
||
document.getElementById("listMsg").textContent = "";
|
||
} catch (e) { document.getElementById("listMsg").className = "msg err"; document.getElementById("listMsg").textContent = e.message; }
|
||
}
|
||
async function toggleGroup(gid, enabled) {
|
||
try { await api(`/groups/${gid}/enabled`, { method: "POST", body: JSON.stringify({ enabled }) }); }
|
||
catch (e) { alert(e.message); route(); }
|
||
}
|
||
|
||
/* ---------- 群详情(管理) ---------- */
|
||
async function renderGroupDetail(gid) {
|
||
const content = document.getElementById("content");
|
||
content.innerHTML = pageHead(`群管理 #${gid}`,
|
||
`<a href="#/groups"><button class="ghost">← 返回</button></a>
|
||
<button class="danger" onclick="clearGroup(${gid})">清空该群全部数据</button>`) +
|
||
`<div class="card"><h2>参与者</h2>
|
||
<div class="row"><input id="addUserId" placeholder="QQ 号" type="number">
|
||
<button onclick="addParticipant(${gid})">加入人设名单</button>
|
||
<span class="muted">仅名单内成员会被采集</span></div>
|
||
<table><thead><tr><th>QQ</th><th>语料条数</th><th></th></tr></thead><tbody id="participantRows"></tbody></table></div>
|
||
<div class="card"><div class="tabs">
|
||
<button id="tabLogs" class="active" onclick="switchTab('logs')">语料</button>
|
||
<button id="tabImpressions" onclick="switchTab('impressions')">印象</button>
|
||
<button id="tabSummaries" onclick="switchTab('summaries')">画像</button>
|
||
</div>
|
||
<div id="tabLogsView">
|
||
<div class="row"><input id="logUserId" placeholder="按 QQ 过滤" type="number">
|
||
<button onclick="loadLogs(${gid}, 1)">查询</button>
|
||
<button class="danger" onclick="clearLogs(${gid})">删除全部语料</button></div>
|
||
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
|
||
<tbody id="logRows"></tbody></table>
|
||
<div class="row" id="logPager" style="margin-top:10px"></div>
|
||
</div>
|
||
<div id="tabImpressionsView" class="hidden">
|
||
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
|
||
<tbody id="impressionRows"></tbody></table>
|
||
</div>
|
||
<div id="tabSummariesView" class="hidden"><div id="summaryList"></div></div>
|
||
</div>`;
|
||
window._gid = gid; window._logPage = 1;
|
||
loadParticipants(gid); switchTab("logs"); loadLogs(gid, 1);
|
||
}
|
||
async function loadParticipants(gid) {
|
||
const tbody = document.getElementById("participantRows");
|
||
try {
|
||
const items = await api(`/groups/${gid}/participants`);
|
||
tbody.innerHTML = items.map(p => `<tr><td>${p.user_id}</td><td>${p.corpus_count}</td>
|
||
<td><button class="danger" onclick="removeParticipant(${gid}, ${p.user_id})">移除</button></td></tr>`).join("")
|
||
|| `<tr><td colspan="3" class="muted">暂无参与者</td></tr>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function addParticipant(gid) {
|
||
const uid = document.getElementById("addUserId").value.trim();
|
||
if (!uid) return;
|
||
try { await api(`/groups/${gid}/participants`, { method: "POST", body: JSON.stringify({ user_id: Number(uid) }) });
|
||
document.getElementById("addUserId").value = ""; loadParticipants(gid); }
|
||
catch (e) { alert(e.message); }
|
||
}
|
||
async function removeParticipant(gid, uid) {
|
||
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
|
||
await api(`/groups/${gid}/participants/${uid}`, { method: "DELETE" });
|
||
loadParticipants(gid);
|
||
}
|
||
function switchTab(tab) {
|
||
["logs", "impressions", "summaries"].forEach(t => {
|
||
const name = t[0].toUpperCase() + t.slice(1);
|
||
document.getElementById(`tab${name}`).classList.toggle("active", t === tab);
|
||
document.getElementById(`tab${name}View`).classList.toggle("hidden", t !== tab);
|
||
});
|
||
const gid = window._gid;
|
||
if (tab === "logs") loadLogs(gid, 1);
|
||
if (tab === "impressions") loadImpressions(gid);
|
||
if (tab === "summaries") loadSummaries(gid);
|
||
}
|
||
async function loadLogs(gid, page) {
|
||
window._logPage = page;
|
||
const tbody = document.getElementById("logRows");
|
||
const uid = document.getElementById("logUserId").value.trim();
|
||
const q = new URLSearchParams({ page, per_page: 50 });
|
||
if (uid) q.set("user_id", uid);
|
||
try {
|
||
const data = await api(`/groups/${gid}/logs?${q}`);
|
||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
|
||
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
|
||
<td>${esc(r.content)}</td></tr>`).join("") || `<tr><td colspan="5" class="muted">暂无语料</td></tr>`;
|
||
const pages = Math.max(1, Math.ceil(data.total / 50));
|
||
document.getElementById("logPager").innerHTML =
|
||
`<button class="ghost" ${page <= 1 ? "disabled" : ""} onclick="loadLogs(${gid}, ${page - 1})">上一页</button>
|
||
<span class="muted">第 ${page}/${pages} 页 · 共 ${data.total} 条</span>
|
||
<button class="ghost" ${page >= pages ? "disabled" : ""} onclick="loadLogs(${gid}, ${page + 1})">下一页</button>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function clearLogs(gid) {
|
||
if (!confirm("删除该群全部语料?印象/画像保留")) return;
|
||
try { await api(`/groups/${gid}/logs`, { method: "DELETE" }); loadLogs(gid, 1); } catch (e) { alert(e.message); }
|
||
}
|
||
async function loadImpressions(gid) {
|
||
const tbody = document.getElementById("impressionRows");
|
||
try {
|
||
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
|
||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
|
||
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|
||
|| `<tr><td colspan="5" class="muted">暂无印象</td></tr>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function loadSummaries(gid) {
|
||
const box = document.getElementById("summaryList");
|
||
try {
|
||
const items = await api(`/groups/${gid}/summaries`);
|
||
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|
||
|| `<p class="muted">暂无画像(语料积累后由总结路径生成)</p>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function clearGroup(gid) {
|
||
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
|
||
try { await api(`/groups/${gid}`, { method: "DELETE" }); location.hash = "#/groups"; route(); } catch (e) { alert(e.message); }
|
||
}
|
||
|
||
/* ---------- 数据浏览(跨群) ---------- */
|
||
async function renderDataBrowser(content) {
|
||
content.innerHTML = pageHead("数据浏览") +
|
||
`<div class="card"><div class="row">
|
||
<select id="dataGroupSelect" onchange="dataSelectGroup()"><option value="">选择群…</option></select>
|
||
<span class="muted" id="dataGroupInfo"></span></div>
|
||
<div class="tabs">
|
||
<button id="dTabLogs" class="active" onclick="dataTab('logs')">语料</button>
|
||
<button id="dTabImpressions" onclick="dataTab('impressions')">印象</button>
|
||
<button id="dTabSummaries" onclick="dataTab('summaries')">画像</button>
|
||
</div>
|
||
<div id="dTabLogsView">
|
||
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
|
||
<tbody id="dLogRows"><tr><td colspan="5" class="muted">请先选择群</td></tr></tbody></table>
|
||
</div>
|
||
<div id="dTabImpressionsView" class="hidden">
|
||
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
|
||
<tbody id="dImpressionRows"></tbody></table>
|
||
</div>
|
||
<div id="dTabSummariesView" class="hidden"><div id="dSummaryList"></div></div>
|
||
</div>`;
|
||
try {
|
||
const groups = await api("/groups");
|
||
document.getElementById("dataGroupSelect").innerHTML =
|
||
`<option value="">选择群…</option>` + groups.map(g => `<option value="${g.group_id}">${esc(g.group_name)}(${g.group_id})</option>`).join("");
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function dataSelectGroup() {
|
||
const gid = Number(document.getElementById("dataGroupSelect").value);
|
||
window._dataGid = gid;
|
||
document.getElementById("dataGroupInfo").textContent = gid ? "" : "";
|
||
if (!gid) return;
|
||
dataTab("logs");
|
||
}
|
||
function dataTab(tab) {
|
||
["logs", "impressions", "summaries"].forEach(t => {
|
||
const name = "d" + t[0].toUpperCase() + t.slice(1);
|
||
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}`).classList.toggle("active", t === tab);
|
||
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}View`).classList.toggle("hidden", t !== tab);
|
||
});
|
||
const gid = window._dataGid;
|
||
if (!gid) return;
|
||
if (tab === "logs") loadDataLogs(gid);
|
||
if (tab === "impressions") loadDataImpressions(gid);
|
||
if (tab === "summaries") loadDataSummaries(gid);
|
||
}
|
||
async function loadDataLogs(gid) {
|
||
const tbody = document.getElementById("dLogRows");
|
||
try {
|
||
const data = await api(`/groups/${gid}/logs?page=1&per_page=100`);
|
||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
|
||
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
|
||
<td>${esc(r.content)}</td></tr>`).join("")
|
||
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function loadDataImpressions(gid) {
|
||
const tbody = document.getElementById("dImpressionRows");
|
||
try {
|
||
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
|
||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
|
||
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|
||
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function loadDataSummaries(gid) {
|
||
const box = document.getElementById("dSummaryList");
|
||
try {
|
||
const items = await api(`/groups/${gid}/summaries`);
|
||
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|
||
|| `<p class="muted">暂无数据</p>`;
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
|
||
/* ---------- 设置 ---------- */
|
||
async function renderSettings(content) {
|
||
content.innerHTML = pageHead("设置") + `<div class="card" id="settingsBody"><p class="muted">加载中…</p></div>`;
|
||
try {
|
||
const s = await api("/settings");
|
||
const rows = [
|
||
["Web 登录账号", s.web_username],
|
||
["Web 后台启用", s.web_enable ? "是" : "否"],
|
||
["长文截断上限", `${s.thresholds.max_content_len} 字`],
|
||
["复读检测窗口", `${s.thresholds.repeat_window} 条`],
|
||
["刷屏采样间隔", `${s.thresholds.throttle_seconds} 秒`],
|
||
["单用户语料滚动上限", `${s.thresholds.rolling_window} 条`],
|
||
];
|
||
document.getElementById("settingsBody").innerHTML =
|
||
`<h2>当前配置(只读)</h2>
|
||
<table>${rows.map(r => `<tr><th style="width:200px">${r[0]}</th><td>${esc(r[1])}</td></tr>`).join("")}</table>
|
||
<p class="muted" style="margin-top:12px">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>`;
|
||
} catch (e) { document.getElementById("settingsBody").innerHTML = `<p class="msg err">${esc(e.message)}</p>`; }
|
||
}
|
||
|
||
/* ---------- 启动 ---------- */
|
||
window.addEventListener("hashchange", route);
|
||
if (TOKEN) { showMain(); route(); } else { showLogin(); }
|
||
</script>
|
||
</body>
|
||
</html>
|