Files
HeXi/hexi/plugins/nonebot_plugin_galgame_card/web/index.html
T
sansenhoshiandClaude b61d09f09f Add HeXi bot codebase: custom plugins, web frontends, tests
- 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>
2026-09-01 13:13:40 +08:00

371 lines
20 KiB
HTML
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.
<!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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[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>