项目改造

This commit is contained in:
2026-09-03 15:44:57 +08:00
parent 24d7c64e17
commit 2849a6250c
60 changed files with 166 additions and 13754 deletions
@@ -18,14 +18,13 @@ nonebot_plugin_galgame_card/
├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary)
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
├── config.py 插件配置(WEB_ENABLE/WEB_USERNAME/WEB_PASSWORD/WEB_SECRET_KEY)
├── web.py Web 管理后台后端(/galgame_card/api/*,JWT 登录,仿 learning_chat)
├── web/index.html 管理后台前端(单文件,无需构建)
├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub)
├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_auth)
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
└── CLAUDE.md 本文件
```
**Web 管理后台**:`http://<host>:<port>/galgame_card`(默认 admin/galgame,可在 .env 改 WEB_*)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。路由在 `@driver.on_startup` 里注册,改后端需重启 bot。
**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。
## 核心设计(速览,细节见 DESIGN.md)
@@ -69,7 +68,7 @@ poetry run pytest
- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增)
- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库)
- ✅ Web 管理后台:/galgame_card(群开关、参与者、数据浏览/清理;Chrome 已实测)
- ✅ Web 管理后台:嵌入 /hub(/api/galgame_card + hexi/web 页面;群开关、参与者、数据浏览/清理)
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
- ⏳ 呈现层:人物卡展示/图片渲染
@@ -28,7 +28,6 @@ except ImportError:
from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
from . import handlers as _handlers # noqa: E402
from hexi.web_config import register_plugin_config # noqa: E402
@@ -57,7 +56,7 @@ def _get_galgame_config_values() -> dict:
conf = getattr(conf_mod, "config", None) if conf_mod else None
if conf is None:
return {}
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
keys = ("image_vision_enabled",)
return {k: getattr(conf, k, None) for k in keys}
@@ -65,10 +64,6 @@ register_plugin_config(
__name__,
{
"fields": [
{"key": "web_enable", "label": "Web 后台启用", "type": "bool", "default": True},
{"key": "web_username", "label": "Web 登录账号", "type": "string", "default": "admin"},
{"key": "web_password", "label": "Web 登录密码", "type": "password", "default": "galgame", "secret": True},
{"key": "web_secret_key", "label": "Web 签名密钥", "type": "password", "default": "galgame-card-secret-key", "secret": True},
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
]
},
@@ -1,6 +1,6 @@
"""插件配置(Web 管理后台)
"""插件配置(图片识别等运行时行为)。
env 变量名与字段名对应(大写):WEB_ENABLE / WEB_USERNAME / WEB_PASSWORD / WEB_SECRET_KEY
access 由统一 Web 管理台 /hub 提供,插件不再自持 Web 鉴权/开关。
"""
from nonebot import get_plugin_config
@@ -8,10 +8,6 @@ from pydantic import BaseModel
class Config(BaseModel):
web_enable: bool = True
web_username: str = "admin"
web_password: str = "galgame"
web_secret_key: str = "galgame-card-secret-key"
# 图片多模态识别(预留接口,当前未启用;将来接入后置 True)
image_vision_enabled: bool = False
@@ -1,266 +0,0 @@
"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
"""
import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, Header, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from jose import jwt
from nonebot import get_adapter, get_app, get_driver, logger
from nonebot.adapters.onebot.v11 import Adapter, Bot
from pydantic import BaseModel
from . import repository
from .config import config
API_PREFIX = "/galgame_card/api"
WEB_DIR = Path(__file__).parent / "web"
driver = get_driver()
def authentication():
def inner(token: Optional[str] = Header(None, alias="Authorization")):
try:
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
if not payload.get("username") or payload["username"] != config.web_username:
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class LoginModel(BaseModel):
username: str
password: str
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _err(code: int, msg: str) -> JSONResponse:
return JSONResponse({"status": code, "msg": msg})
async def _first_bot() -> Optional[Bot]:
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
@driver.on_startup
async def init_web():
if not config.web_enable:
return
app = get_app()
@app.post(f"{API_PREFIX}/login")
async def login(user: LoginModel):
if user.username != config.web_username or user.password != config.web_password:
return _err(-100, "登录失败,请确认账号密码无误")
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
},
config.web_secret_key,
algorithm="HS256",
)
return _ok({"token": token}, "登录成功")
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def groups():
"""管理中的群:数据库直读,不依赖 OneBot"""
rows = await repository.list_groups()
corpus = await repository.corpus_counts_by_group()
impressions = await repository.impression_counts_by_group()
summaries = await repository.summary_counts_by_group()
participants = await repository.participant_counts_by_group()
result = []
for row in rows:
gid = row.group_id
result.append(
{
"group_id": gid,
"group_name": row.group_name,
"enabled": row.enabled,
"participants": participants.get(gid, 0),
"corpus_count": corpus.get(gid, 0),
"impression_count": impressions.get(gid, 0),
"summary_count": summaries.get(gid, 0),
}
)
return _ok(result)
@app.get(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
async def discover_groups():
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
bot = await _first_bot()
if bot is None:
return _err(-100, "未连接 OneBot,无法获取群列表")
group_list = await bot.get_group_list()
managed_ids = {row.group_id for row in await repository.list_groups()}
result = [
{
"group_id": int(g["group_id"]),
"group_name": g.get("group_name", ""),
"managed": int(g["group_id"]) in managed_ids,
}
for g in group_list
]
await repository.backfill_group_names(
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
)
return _ok(result)
@app.post(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def add_group(data: dict):
gid = int(data.get("group_id") or 0)
if gid <= 0:
return _err(400, "group_id 不合法")
await repository.add_group(gid, str(data.get("group_name") or "").strip())
return _ok(msg="已加入管理列表")
@app.post(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
async def set_group_enabled(group_id: int, data: dict):
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def participants(group_id: int):
members = await repository.joined_users(group_id)
stats = dict(await repository.chat_log_stats_by_user(group_id))
result = [
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
]
return _ok(result)
@app.post(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def add_participant(group_id: int, data: dict):
user_id = int(data.get("user_id") or 0)
if user_id <= 0:
return _err(400, "user_id 不合法")
if await repository.is_joined(user_id, group_id):
return _err(400, "已在人设名单中")
await repository.join(user_id, group_id)
return _ok(msg="已加入")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def logs(group_id: int, user_id: Optional[int] = None, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_logs(group_id, user_id, offset, per_page)
total = await repository.count_logs_filtered(group_id, user_id)
result = [
{
"id": row.id,
"user_id": row.user_id,
"nickname": row.nickname,
"content": row.content,
"target_user_id": row.target_user_id,
"target_inherited": row.target_inherited,
"follows_id": row.follows_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": total})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_impressions_group(group_id, offset, per_page)
result = [
{
"id": row.id,
"user_id": row.user_id,
"content": row.content,
"cover_from_id": row.cover_from_id,
"cover_to_id": row.cover_to_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": await repository.count_impressions_group(group_id)})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
async def summaries(group_id: int):
items = await repository.list_summaries_group(group_id)
result = [
{
"version": row.version,
"card_text": row.card_text,
"corpus_count": row.corpus_count,
"impression_count": row.impression_count,
"model": row.model,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok(result)
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def delete_logs(group_id: int, user_id: Optional[int] = None):
count = await repository.delete_logs(group_id, user_id)
return _ok(msg=f"已删除 {count} 条语料")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
async def settings():
from . import processor
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
"throttle_seconds": processor.THROTTLE_SECONDS,
"rolling_window": repository.ROLLING_WINDOW,
},
}
)
# React 管理后台(web/persona-admin 构建产物)
dist_dir = WEB_DIR / "persona-admin" / "dist"
if (dist_dir / "index.html").exists():
app.mount(
"/galgame_card/assets",
StaticFiles(directory=dist_dir / "assets"),
name="galgame_card_assets",
)
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
return FileResponse(dist_dir / "index.html")
else:
# 构建产物缺失时回退到旧版单文件页面
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")
@@ -1,370 +0,0 @@
<!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>
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>群聊人设卡 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,30 +0,0 @@
{
"name": "persona-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.0",
"@heroui/theme": "^2.4.26",
"framer-motion": "^11.11.17",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,112 +0,0 @@
import { useEffect } from "react";
import {
Navigate,
NavLink,
Outlet,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
import { Button, Tooltip } from "@heroui/react";
import { useTheme } from "next-themes";
import { clearToken, hasToken } from "./api";
import Login from "./pages/Login";
import GroupList from "./pages/GroupList";
import GroupDetail from "./pages/GroupDetail";
import DataBrowser from "./pages/DataBrowser";
import Settings from "./pages/Settings";
const NAV_ITEMS = [
{ to: "/groups", label: "群管理", icon: "◈" },
{ to: "/data", label: "数据浏览", icon: "▤" },
{ to: "/settings", label: "设置", icon: "⚙" },
];
function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
useEffect(() => {
if (!hasToken()) navigate("/login", { replace: true });
}, [location.pathname]);
const logout = () => {
clearToken();
navigate("/login", { replace: true });
};
return (
<div className="flex h-screen overflow-hidden">
<aside className="w-56 shrink-0 border-r border-default-100 bg-content1 flex flex-col">
<div className="px-5 py-5 text-lg font-bold tracking-wide border-b border-default-100">
群聊人设卡
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors ${
isActive
? "bg-primary/15 text-primary font-medium"
: "text-default-500 hover:text-foreground hover:bg-default-100/50"
}`
}
>
<span className="w-4 text-center">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div className="px-3 py-4 border-t border-default-100 flex items-center gap-2">
<Tooltip content={isDark ? "切换亮色" : "切换暗色"} placement="right">
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => setTheme(isDark ? "light" : "dark")}
aria-label="切换主题"
>
{isDark ? "☀" : "🌙"}
</Button>
</Tooltip>
<Tooltip content="退出登录" placement="right">
<Button
fullWidth
variant="light"
color="danger"
size="sm"
onPress={logout}
className="justify-start"
>
⏻ 退出登录
</Button>
</Tooltip>
</div>
</aside>
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
);
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/groups" replace />} />
<Route path="/groups" element={<GroupList />} />
<Route path="/groups/:gid" element={<GroupDetail />} />
<Route path="/data" element={<DataBrowser />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/groups" replace />} />
</Route>
</Routes>
);
}
@@ -1,92 +0,0 @@
const API = "/galgame_card/api";
let token: string = localStorage.getItem("gc_token") || "";
export function setToken(t: string) {
token = t;
localStorage.setItem("gc_token", t);
}
export function clearToken() {
token = "";
localStorage.removeItem("gc_token");
}
export function hasToken() {
return !!token;
}
export async function api<T = any>(path: string, options: RequestInit = {}): Promise<T> {
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) {
clearToken();
window.location.hash = "#/login";
}
throw new Error(data.msg || "请求失败");
}
return data.data as T;
}
/* ---------- 类型 ---------- */
export interface GroupInfo {
group_id: number;
group_name: string;
enabled: boolean;
participants: number;
corpus_count: number;
impression_count: number;
summary_count: number;
}
export interface DiscoverGroup {
group_id: number;
group_name: string;
managed: boolean;
}
export interface Participant {
user_id: number;
corpus_count: number;
}
export interface LogItem {
id: number;
user_id: number;
nickname: string;
content: string;
target_user_id: number | null;
target_inherited: boolean;
follows_id: number | null;
created_at: string;
}
export interface ImpressionItem {
id: number;
user_id: number;
content: string;
cover_from_id: number;
cover_to_id: number;
created_at: string;
}
export interface SummaryItem {
version: number;
card_text: string;
corpus_count: number;
impression_count: number;
model: string;
created_at: string;
}
export interface SettingsInfo {
web_username: string;
web_enable: boolean;
thresholds: {
max_content_len: number;
repeat_window: number;
throttle_seconds: number;
rolling_window: number;
};
}
@@ -1,24 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
font-family: "Microsoft YaHei", -apple-system, sans-serif;
}
/* 侧栏滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
@apply bg-default-700 rounded-full;
}
::-webkit-scrollbar-track {
background: transparent;
}
@@ -1,20 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { HeroUIProvider, ToastProvider } from "@heroui/react";
import { ThemeProvider } from "next-themes";
import { HashRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<HeroUIProvider>
<ToastProvider toastProps={{ placement: "top-center" }} />
<HashRouter>
<App />
</HashRouter>
</HeroUIProvider>
</ThemeProvider>
</React.StrictMode>
);
@@ -1,171 +0,0 @@
import { useEffect, useState } from "react";
import {
Card,
CardBody,
Select,
SelectItem,
Spinner,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, GroupInfo, ImpressionItem, LogItem, SummaryItem } from "../api";
export default function DataBrowser() {
const [groups, setGroups] = useState<GroupInfo[]>([]);
const [gid, setGid] = useState<number>(0);
const [tab, setTab] = useState("logs");
const [logs, setLogs] = useState<LogItem[]>([]);
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
api<GroupInfo[]>("/groups")
.then(setGroups)
.catch((e: any) => addToast({ title: e.message, color: "danger" }));
}, []);
useEffect(() => {
if (!gid) return;
setLoading(true);
const loaders: Record<string, Promise<any>> = {
logs: api(`/groups/${gid}/logs?page=1&per_page=100`).then((d: any) =>
setLogs(d.items)
),
impressions: api(`/groups/${gid}/impressions?page=1&per_page=100`).then((d: any) =>
setImpressions(d.items)
),
summaries: api(`/groups/${gid}/summaries`).then(setSummaries),
};
Promise.all([loaders.logs, loaders.impressions, loaders.summaries]).finally(() =>
setLoading(false)
);
}, [gid]);
return (
<div>
<h1 className="text-xl font-bold mb-5">数据浏览</h1>
<Card className="bg-content1">
<CardBody>
<Select
label="选择群"
placeholder="选择要浏览的群"
className="max-w-sm mb-4"
variant="bordered"
selectedKeys={gid ? [String(gid)] : []}
onSelectionChange={(keys) => {
const k = Array.from(keys)[0];
if (k) setGid(Number(k));
}}
>
{groups.map((g) => (
<SelectItem key={String(g.group_id)}>
{g.group_name || `群 ${g.group_id}`}({g.group_id})
</SelectItem>
))}
</Select>
{!gid && <p className="text-default-500 py-6">请先选择群</p>}
{loading && gid && (
<div className="flex justify-center py-16">
<Spinner />
</div>
)}
{!loading && gid && (
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
<Tab key="logs" title="语料">
<Table aria-label="语料" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>发送者</TableColumn>
<TableColumn>发给</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>内容(已脱敏)</TableColumn>
</TableHeader>
<TableBody items={logs} emptyContent={gid ? "暂无数据" : "请先选择群"}>
{(l) => (
<TableRow key={l.id}>
<TableCell>{l.id}</TableCell>
<TableCell>
<div>{l.nickname || "—"}</div>
<div className="text-xs text-default-500">{l.user_id}</div>
</TableCell>
<TableCell>
{l.target_user_id ? (
<span>
→ {l.target_user_id}
{l.target_inherited && (
<span className="text-xs text-warning ml-1">延续</span>
)}
</span>
) : (
<span className="text-default-500">—</span>
)}
</TableCell>
<TableCell className="text-default-500">{l.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="impressions" title="印象">
<Table aria-label="印象" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>覆盖语料区间</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>印象内容</TableColumn>
</TableHeader>
<TableBody items={impressions} emptyContent={gid ? "暂无数据" : "请先选择群"}>
{(r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell>{r.user_id}</TableCell>
<TableCell className="text-default-500">
#{r.cover_from_id} ~ #{r.cover_to_id}
</TableCell>
<TableCell className="text-default-500">{r.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="summaries" title="画像">
{summaries.length === 0 ? (
<p className="text-default-500 py-6">{gid ? "暂无数据" : "请先选择群"}</p>
) : (
summaries.map((s) => (
<div key={s.version} className="mb-4">
<div className="text-sm text-default-500 mb-2">
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
{s.impression_count} 条
</div>
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
{s.card_text}
</pre>
</div>
))
)}
</Tab>
</Tabs>
)}
</CardBody>
</Card>
</div>
);
}
@@ -1,334 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
Button,
Card,
CardBody,
Input,
Spinner,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, ImpressionItem, LogItem, Participant, SummaryItem } from "../api";
export default function GroupDetail() {
const { gid } = useParams();
const gidNum = Number(gid);
const [participants, setParticipants] = useState<Participant[]>([]);
const [addUid, setAddUid] = useState("");
const [tab, setTab] = useState("logs");
const [logs, setLogs] = useState<LogItem[]>([]);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(1);
const [logUid, setLogUid] = useState("");
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
const [loading, setLoading] = useState(true);
const loadParticipants = useCallback(async () => {
try {
setParticipants(await api<Participant[]>(`/groups/${gidNum}/participants`));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
const loadLogs = useCallback(
async (page: number) => {
const q = new URLSearchParams({ page: String(page), per_page: "50" });
if (logUid) q.set("user_id", logUid);
try {
const data = await api<{ items: LogItem[]; total: number }>(
`/groups/${gidNum}/logs?${q}`
);
setLogs(data.items);
setLogTotal(data.total);
setLogPage(page);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
},
[gidNum, logUid]
);
const loadImpressions = useCallback(async () => {
try {
const data = await api<{ items: ImpressionItem[] }>(
`/groups/${gidNum}/impressions?page=1&per_page=100`
);
setImpressions(data.items);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
const loadSummaries = useCallback(async () => {
try {
setSummaries(await api<SummaryItem[]>(`/groups/${gidNum}/summaries`));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
useEffect(() => {
setLoading(true);
Promise.all([loadParticipants(), loadLogs(1)]).finally(() => setLoading(false));
}, [loadParticipants, loadLogs]);
useEffect(() => {
if (tab === "impressions") loadImpressions();
if (tab === "summaries") loadSummaries();
}, [tab]);
/* ---------- 操作 ---------- */
const addParticipant = async () => {
const uid = Number(addUid.trim());
if (!uid) return;
try {
await api(`/groups/${gidNum}/participants`, {
method: "POST",
body: JSON.stringify({ user_id: uid }),
});
setAddUid("");
loadParticipants();
addToast({ title: `已加入 ${uid}`, color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const removeParticipant = async (uid: number) => {
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
try {
await api(`/groups/${gidNum}/participants/${uid}`, { method: "DELETE" });
loadParticipants();
addToast({ title: "已移除", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const clearLogs = async () => {
if (!confirm("删除该群全部语料?印象/画像保留")) return;
try {
await api(`/groups/${gidNum}/logs`, { method: "DELETE" });
loadLogs(1);
addToast({ title: "已删除全部语料", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const clearGroup = async () => {
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
try {
await api(`/groups/${gidNum}/data`, { method: "DELETE" });
addToast({ title: "已清空", color: "success" });
setTimeout(() => (window.location.hash = "#/groups"), 600);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const pages = Math.max(1, Math.ceil(logTotal / 50));
if (loading) {
return (
<div className="flex justify-center py-24">
<Spinner label="加载中…" />
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-5">
<Link to="/groups">
<Button size="sm" variant="flat" isIconOnly aria-label="返回">
←
</Button>
</Link>
<h1 className="text-xl font-bold flex-1">群管理 #{gidNum}</h1>
<Button size="sm" color="danger" variant="flat" onPress={clearGroup}>
清空该群全部数据
</Button>
</div>
{/* 参与者 */}
<Card className="bg-content1 mb-5">
<CardBody className="gap-3">
<h2 className="font-semibold">参与者</h2>
<div className="flex gap-2 max-w-md">
<Input
placeholder="QQ 号"
type="number"
value={addUid}
onValueChange={setAddUid}
variant="bordered"
size="sm"
onKeyDown={(e) => e.key === "Enter" && addParticipant()}
/>
<Button size="sm" color="primary" onPress={addParticipant}>
加入人设名单
</Button>
</div>
<Table aria-label="参与者" removeWrapper>
<TableHeader>
<TableColumn>QQ</TableColumn>
<TableColumn>语料条数</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={participants} emptyContent="暂无参与者">
{(p) => (
<TableRow key={p.user_id}>
<TableCell>{p.user_id}</TableCell>
<TableCell>{p.corpus_count}</TableCell>
<TableCell>
<Button size="sm" color="danger" variant="light" onPress={() => removeParticipant(p.user_id)}>
移除
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
{/* 数据页签 */}
<Card className="bg-content1">
<CardBody>
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
<Tab key="logs" title="语料">
<div className="flex items-center gap-2 mb-3">
<Input
placeholder="按 QQ 过滤"
type="number"
value={logUid}
onValueChange={setLogUid}
variant="bordered"
size="sm"
className="max-w-40"
/>
<Button size="sm" variant="flat" onPress={() => loadLogs(1)}>
查询
</Button>
<Button size="sm" color="danger" variant="flat" onPress={clearLogs}>
删除全部语料
</Button>
</div>
<Table aria-label="语料" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>发送者</TableColumn>
<TableColumn>发给</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>内容(已脱敏)</TableColumn>
</TableHeader>
<TableBody items={logs} emptyContent="暂无语料">
{(l) => (
<TableRow key={l.id}>
<TableCell>{l.id}</TableCell>
<TableCell>
<div>{l.nickname || "—"}</div>
<div className="text-xs text-default-500">{l.user_id}</div>
</TableCell>
<TableCell>
{l.target_user_id ? (
<span>
→ {l.target_user_id}
{l.target_inherited && (
<span className="text-xs text-warning ml-1">延续</span>
)}
</span>
) : (
<span className="text-default-500">—</span>
)}
</TableCell>
<TableCell className="text-default-500">{l.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center gap-3 mt-3">
<Button
size="sm"
variant="flat"
isDisabled={logPage <= 1}
onPress={() => loadLogs(logPage - 1)}
>
上一页
</Button>
<span className="text-sm text-default-500">
第 {logPage}/{pages} 页 · 共 {logTotal} 条
</span>
<Button
size="sm"
variant="flat"
isDisabled={logPage >= pages}
onPress={() => loadLogs(logPage + 1)}
>
下一页
</Button>
</div>
</Tab>
<Tab key="impressions" title="印象">
<Table aria-label="印象" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>覆盖语料区间</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>印象内容</TableColumn>
</TableHeader>
<TableBody items={impressions} emptyContent="暂无印象">
{(r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell>{r.user_id}</TableCell>
<TableCell className="text-default-500">
#{r.cover_from_id} ~ #{r.cover_to_id}
</TableCell>
<TableCell className="text-default-500">{r.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="summaries" title="画像">
{summaries.length === 0 ? (
<p className="text-default-500 py-6">暂无画像(语料积累后由总结路径生成)</p>
) : (
summaries.map((s) => (
<div key={s.version} className="mb-4">
<div className="text-sm text-default-500 mb-2">
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
{s.impression_count} 条
</div>
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
{s.card_text}
</pre>
</div>
))
)}
</Tab>
</Tabs>
</CardBody>
</Card>
</div>
);
}
@@ -1,227 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
Button,
Card,
CardBody,
Checkbox,
CheckboxGroup,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Switch,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
useDisclosure,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, DiscoverGroup, GroupInfo } from "../api";
export default function GroupList() {
const [groups, setGroups] = useState<GroupInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// 添加群弹窗
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [discoverList, setDiscoverList] = useState<DiscoverGroup[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [discoverLoading, setDiscoverLoading] = useState(false);
const [adding, setAdding] = useState(false);
const load = async () => {
setLoading(true);
setError("");
try {
setGroups(await api<GroupInfo[]>("/groups"));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const toggle = async (g: GroupInfo, enabled: boolean) => {
try {
await api(`/groups/${g.group_id}/enabled`, {
method: "POST",
body: JSON.stringify({ enabled }),
});
g.enabled = enabled;
setGroups([...groups]);
addToast({
title: enabled ? "已开启采集" : "已关闭采集",
description: `${g.group_name || g.group_id}`,
color: enabled ? "success" : "warning",
});
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const removeGroup = async (g: GroupInfo) => {
if (!confirm(`将 ${g.group_name || g.group_id} 移出管理列表?其数据保留`)) return;
try {
await api(`/groups/${g.group_id}`, { method: "DELETE" });
load();
addToast({ title: "已移出管理列表", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
/* ---------- 添加群 ---------- */
const openDiscover = async () => {
onOpen();
setDiscoverLoading(true);
setSelected([]);
try {
setDiscoverList(await api<DiscoverGroup[]>("/groups/discover"));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
} finally {
setDiscoverLoading(false);
}
};
const confirmAdd = async () => {
const picked = discoverList.filter(
(g) => selected.includes(String(g.group_id)) && !g.managed
);
if (picked.length === 0) return;
setAdding(true);
try {
for (const g of picked) {
await api("/groups", {
method: "POST",
body: JSON.stringify({ group_id: g.group_id, group_name: g.group_name }),
});
}
addToast({ title: `已加入 ${picked.length} 个群`, color: "success" });
onOpenChange(false);
load();
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
} finally {
setAdding(false);
}
};
return (
<div>
<div className="flex items-center justify-between mb-5">
<h1 className="text-xl font-bold">群管理</h1>
<div className="flex items-center gap-2">
<Button size="sm" variant="flat" onPress={load}>
刷新
</Button>
<Button size="sm" color="primary" onPress={openDiscover}>
+ 添加群
</Button>
</div>
</div>
<Card className="bg-content1">
<CardBody>
{loading ? (
<div className="flex justify-center py-16">
<Spinner label="加载中…" />
</div>
) : error ? (
<p className="text-danger text-center py-8">{error}</p>
) : (
<Table aria-label="管理群列表" removeWrapper>
<TableHeader>
<TableColumn>群</TableColumn>
<TableColumn>采集开关</TableColumn>
<TableColumn>参与者</TableColumn>
<TableColumn>语料</TableColumn>
<TableColumn>印象</TableColumn>
<TableColumn>画像</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={groups} emptyContent="还没有管理的群,点右上角「添加群」">
{(g) => (
<TableRow key={g.group_id}>
<TableCell>
<div className="font-medium">{g.group_name || `群 ${g.group_id}`}</div>
<div className="text-xs text-default-500">{g.group_id}</div>
</TableCell>
<TableCell>
<Switch size="sm" isSelected={g.enabled} onValueChange={(v) => toggle(g, v)} />
</TableCell>
<TableCell>{g.participants}</TableCell>
<TableCell>{g.corpus_count}</TableCell>
<TableCell>{g.impression_count}</TableCell>
<TableCell>{g.summary_count}</TableCell>
<TableCell>
<div className="flex gap-2">
<Link to={`/groups/${g.group_id}`}>
<Button size="sm" color="primary" variant="flat">
管理
</Button>
</Link>
<Button size="sm" color="danger" variant="light" onPress={() => removeGroup(g)}>
移除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardBody>
</Card>
{/* 添加群弹窗 */}
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="lg" scrollBehavior="inside">
<ModalContent>
<ModalHeader>添加群到管理列表</ModalHeader>
<ModalBody>
{discoverLoading ? (
<div className="flex justify-center py-10">
<Spinner label="正在从 OneBot 获取群列表…" />
</div>
) : (
<CheckboxGroup value={selected} onValueChange={setSelected}>
{discoverList.map((g) => (
<div key={g.group_id} className="flex items-center justify-between py-1">
<Checkbox value={String(g.group_id)} isDisabled={g.managed}>
{g.group_name || `群 ${g.group_id}`}
<span className="text-xs text-default-500 ml-2">{g.group_id}</span>
</Checkbox>
{g.managed && <span className="text-xs text-success">已管理</span>}
</div>
))}
</CheckboxGroup>
)}
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={() => onOpenChange(false)}>
取消
</Button>
<Button
color="primary"
isDisabled={selected.length === 0 || discoverLoading}
isLoading={adding}
onPress={confirmAdd}
>
加入管理({selected.filter((s) => !discoverList.find((g) => String(g.group_id) === s)?.managed).length})
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
@@ -1,58 +0,0 @@
import { useState } from "react";
import { Button, Card, CardBody, Input } from "@heroui/react";
import { useNavigate } from "react-router-dom";
import { api, setToken } from "../api";
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const submit = async () => {
setLoading(true);
setError("");
try {
const data = await api<{ token: string }>("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setToken(data.token);
navigate("/groups", { replace: true });
} catch (e: any) {
setError(e.message || "登录失败");
} finally {
setLoading(false);
}
};
return (
<div className="h-screen flex items-center justify-center bg-content2">
<Card className="w-80 bg-content1" shadow="lg">
<CardBody className="gap-4 p-8">
<h1 className="text-xl font-bold text-center mb-2">群聊人设卡</h1>
<Input
label="用户名"
value={username}
onValueChange={setUsername}
variant="bordered"
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Input
label="密码"
type="password"
value={password}
onValueChange={setPassword}
variant="bordered"
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
{error && <p className="text-danger text-sm text-center">{error}</p>}
<Button color="primary" isLoading={loading} onPress={submit}>
登 录
</Button>
</CardBody>
</Card>
</div>
);
}
@@ -1,71 +0,0 @@
import { useEffect, useState } from "react";
import { Card, CardBody, Spinner, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
import { api, SettingsInfo } from "../api";
export default function Settings() {
const [info, setInfo] = useState<SettingsInfo | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api<SettingsInfo>("/settings")
.then(setInfo)
.catch((e: any) => setError(e.message));
}, []);
return (
<div>
<h1 className="text-xl font-bold mb-5">设置</h1>
<Card className="bg-content1 max-w-2xl">
<CardBody>
{error ? (
<p className="text-danger">{error}</p>
) : !info ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : (
<>
<h2 className="font-semibold mb-3">当前配置(只读)</h2>
<Table aria-label="配置" removeWrapper>
<TableHeader>
<TableColumn>项</TableColumn>
<TableColumn>值</TableColumn>
</TableHeader>
<TableBody>
<TableRow key="user">
<TableCell>Web 登录账号</TableCell>
<TableCell>{info.web_username}</TableCell>
</TableRow>
<TableRow key="enable">
<TableCell>Web 后台启用</TableCell>
<TableCell>{info.web_enable ? "是" : "否"}</TableCell>
</TableRow>
<TableRow key="len">
<TableCell>长文截断上限</TableCell>
<TableCell>{info.thresholds.max_content_len} 字</TableCell>
</TableRow>
<TableRow key="repeat">
<TableCell>复读检测窗口</TableCell>
<TableCell>{info.thresholds.repeat_window} 条</TableCell>
</TableRow>
<TableRow key="throttle">
<TableCell>刷屏采样间隔</TableCell>
<TableCell>{info.thresholds.throttle_seconds} 秒</TableCell>
</TableRow>
<TableRow key="rolling">
<TableCell>单用户语料滚动上限</TableCell>
<TableCell>{info.thresholds.rolling_window} 条</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="text-default-500 text-sm mt-4">
阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME /
WEB_PASSWORD。
</p>
</>
)}
</CardBody>
</Card>
</div>
);
}
@@ -1,12 +0,0 @@
import { heroui } from "@heroui/theme";
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
plugins: [heroui()],
};
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/galgame_card/",
plugins: [react()],
});
@@ -2,7 +2,7 @@
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
前端由统一管理台 hexi/web 渲染。
"""
from __future__ import annotations
@@ -15,7 +15,6 @@ from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from . import repository
from .config import config
API = require_admin
@@ -42,11 +41,8 @@ async def _first_bot():
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
在 hub 统一登录态下可用;未启用 Web 时返回 None。
鉴权由 hub 统一登录态提供(hexi.web_auth)。
"""
if not config.web_enable:
return None
app = FastAPI(title="Galgame Card API")
@app.get("/groups", dependencies=[API])
@@ -213,8 +209,6 @@ def build_admin_app() -> FastAPI | None:
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
@@ -153,14 +153,34 @@ def count_stratagems() -> int:
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
def list_stratagems_page(page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
def list_stratagems_page(
page: int = 1,
page_size: int = 20,
keyword: str = "",
type: str = "",
backpack: str = "",
) -> tuple[list[dict[str, Any]], int]:
init_db()
total = count_stratagems()
offset = (page - 1) * page_size
where: list[str] = []
params: list[Any] = []
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
where.append("(name LIKE ? OR name_en LIKE ? OR alias LIKE ?)")
params.extend([like, like, like])
if type:
where.append("type = ?")
params.append(type)
if backpack in ("0", "1"):
where.append("backpack = ?")
params.append(int(backpack))
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
with _connect() as conn:
total = int(conn.execute(f"SELECT COUNT(*) FROM stratagems{where_sql}", params).fetchone()[0])
offset = (page - 1) * page_size
rows = conn.execute(
"SELECT * FROM stratagems ORDER BY id LIMIT ? OFFSET ?",
(page_size, offset),
f"SELECT * FROM stratagems{where_sql} ORDER BY id LIMIT ? OFFSET ?",
(*params, page_size, offset),
).fetchall()
return [_row_to_item(r) for r in rows], total
@@ -34,10 +34,20 @@ def _equip_icon(equip: dict[str, Any]) -> str:
raw = equip.get("path") or ""
if not raw:
return ""
p = basic_path / raw.replace("\\", "/")
if not p.exists():
return ""
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
rel = raw.replace("\\", "/")
# 兼容两种路径存法:插件根相对路径 res/img/...(新/播种数据),
# 以及 res/img 目录相对路径 img/...(旧库迁移前遗留),统一找真实文件。
candidates = [rel]
if rel.startswith("res/"):
candidates.append(rel[len("res/"):])
else:
candidates.append("res/" + rel)
candidates.append("res/img/" + rel)
for cand in candidates:
p = basic_path / cand
if p.exists():
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
return ""
def _item_html(equip: dict[str, Any]) -> str:
@@ -1,21 +1,30 @@
# -*- coding: utf-8 -*-
"""SVG -> PNG 转换 + 可选 HD2 风格边框,用于战备图标上传。"""
"""SVG -> PNG 转换。
与 docs/HD2/run.py 一致:把上传的单张 SVG 按输出尺寸(默认 550x550)栅格化成
透明背景 PNG,不做批量,也不叠加边框。
优先用 cairosvg(与 run.py 完全一致);若运行环境缺 cairo 原生库,则回退到
Chromium canvas 渲染(透明背景、同尺寸),保证 Web 后台上传始终可用。
"""
from __future__ import annotations
import base64
import io
import logging
import re
from pathlib import Path
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
try:
import cairosvg # type: ignore
_HAS_CAIROSVG = True
except Exception: # noqa: BLE001
cairosvg = None
_HAS_CAIROSVG = False
SCHEMA_SIZE = 550 # 与随包 PNG 图标一致
TYPE_COLORS = {
"blue": "#6bb7ea",
"red": "#fe6d6a",
"green": "#7ecb3f",
}
basic_path = Path(__file__).resolve().parents[1]
IMG_DIR = basic_path / "res" / "img" / "helldivers"
@@ -23,49 +32,13 @@ IMG_DIR = basic_path / "res" / "img" / "helldivers"
def sanitize_filename(name: str) -> str:
"""把战备名转成安全的 Windows 文件名(保留中文/空格,去掉非法字符)。"""
s = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
s = re.sub(r'[\/:*?"<>|]', "_", name).strip()
s = re.sub(r"\s+", " ", s)
return s or "strategem"
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
def _apply_frame(src_png: bytes, size: int, frame_color: str) -> bytes:
"""把透明的 SVG 画面叠到 HD2 风格画布上:彩色边框 + 深色内底 + 居中图形。"""
rgb = _hex_to_rgb(frame_color)
img = Image.open(io.BytesIO(src_png)).convert("RGBA")
canvas = Image.new("RGBA", (size, size), rgb + (255,))
draw = ImageDraw.Draw(canvas)
border = max(10, size // 20)
# 外圈用类型色,内部深色
draw.rectangle([0, 0, size - 1, size - 1], fill=rgb + (255,))
draw.rectangle([border, border, size - 1 - border, size - 1 - border], fill=(12, 12, 12, 255))
# 去掉透明边距再居中
alpha = img.getchannel("A")
bbox = alpha.getbbox()
glyph = img.crop(bbox) if bbox else img
inner = size - 2 * border
avail = int(inner * 0.82)
glyph.thumbnail((avail, avail), Image.LANCZOS)
ox = (size - glyph.width) // 2
oy = (size - glyph.height) // 2
canvas.alpha_composite(glyph, (ox, oy))
out = io.BytesIO()
canvas.save(out, format="PNG")
return out.getvalue()
async def _render_svg(svg_text: str, size: int) -> bytes:
"""用 Chromium 的 canvas 把 SVG 光栅化为带透明通道的 PNG。
用 toDataURL 导出而非截图,避免 omit_background 在部分环境把透明背景
拍成白色,从而保住透明 alpha。
"""
async def _render_svg_canvas(svg_text: str, size: int) -> bytes:
"""用 Chromium canvas 把 SVG 光栅化为带透明通道的 PNG(兜底方案)。"""
from playwright.async_api import async_playwright
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg_text.encode("utf-8")).decode()
@@ -92,17 +65,28 @@ async def _render_svg(svg_text: str, size: int) -> bytes:
await page.set_content("<!-- svg to png -->")
data_url = await page.evaluate(script)
await browser.close()
if not data_url:
raise ValueError("SVG 转换失败")
return base64.b64decode(data_url.split(",", 1)[1])
async def svg_to_png(svg_text: str, framed: bool = False, frame_color: str | None = None, size: int = SCHEMA_SIZE) -> bytes:
"""SVG -> PNG。framed=True 时叠加 HD2 风格边框到类型色。"""
async def svg_to_png(svg_text: str, size: int = SCHEMA_SIZE) -> bytes:
"""SVG -> PNG(透明背景,默认 550x550)。
参考 docs/HD2/run.py:cairosvg.svg2png 按 output_width/output_height 栅格化到目标尺寸。
"""
if not svg_text.strip():
raise ValueError("SVG 内容为空")
png = await _render_svg(svg_text, size)
if framed:
png = _apply_frame(png, size, frame_color or TYPE_COLORS["blue"])
return png
if _HAS_CAIROSVG:
try:
return cairosvg.svg2png(
bytestring=svg_text.encode("utf-8"),
output_width=size,
output_height=size,
)
except Exception as e: # noqa: BLE001
logger.warning(f"cairosvg 转换失败,改用 canvas 兜底: {type(e).__name__}: {e}")
return await _render_svg_canvas(svg_text, size)
def save_icon_png(png_bytes: bytes, name: str) -> str:
@@ -111,4 +95,4 @@ def save_icon_png(png_bytes: bytes, name: str) -> str:
filename = sanitize_filename(name) + ".png"
dest = IMG_DIR / filename
dest.write_bytes(png_bytes)
return f"res/img/helldivers/{filename}"
return f"res/img/helldivers/{filename}"
@@ -24,7 +24,7 @@ from ..services.equipment_store import (
list_stratagems_page,
update_stratagem,
)
from ..utils.icon_utils import TYPE_COLORS, save_icon_png, svg_to_png
from ..utils.icon_utils import save_icon_png, svg_to_png
basic_path = Path(__file__).resolve().parents[1]
@@ -39,10 +39,10 @@ def build_admin_app() -> FastAPI | None:
auth = require_admin
@app.get("/stratagems")
async def api_list(page: int = 1, page_size: int = 20, _: dict = auth):
async def api_list(page: int = 1, page_size: int = 20, q: str = "", type: str = "", backpack: str = "", _: dict = auth):
page = max(1, page)
page_size = min(max(1, page_size), 100)
items, total = list_stratagems_page(page, page_size)
items, total = list_stratagems_page(page, page_size, keyword=q, type=type, backpack=backpack)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@app.post("/stratagems")
@@ -86,8 +86,17 @@ def build_admin_app() -> FastAPI | None:
@app.get("/img/{path:path}")
async def icon_file(path: str):
# 兼容多种存量路径:res/img/...(插件根相对,新)、img/...(旧库,res/img 相对)、
# 以及直接/相对 res/img 的路径,统一归一化到 res/img 下的相对路径再解析。
rel = path.replace("\\", "/").lstrip("/")
if rel.startswith("res/img/"):
rel = rel[len("res/img/"):]
elif rel.startswith("res/"):
rel = rel[len("res/"):]
elif rel.startswith("img/"):
rel = rel[len("img/"):]
base = (basic_path / "res" / "img").resolve()
target = (basic_path / "res" / "img" / path).resolve()
target = (basic_path / "res" / "img" / rel).resolve()
if not target.is_relative_to(base) or not target.exists():
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(target)
@@ -96,13 +105,11 @@ def build_admin_app() -> FastAPI | None:
async def api_upload_icon(seed: int, request: Request, _: dict = auth):
body = await _get_body(request)
svg = (body.get("svg") or "").strip()
framed = bool(body.get("framed"))
item = get_stratagem(seed)
if item is None:
raise HTTPException(status_code=404, detail="未找到该战备")
frame_color = TYPE_COLORS.get(item.get("type"), "#6bb7ea")
try:
png = await svg_to_png(svg, framed=framed, frame_color=frame_color)
png = await svg_to_png(svg)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -6,7 +6,6 @@ from nonebot.plugin import PluginMetadata
require("nonebot_plugin_orm")
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
from . import web_api # noqa: E402
from . import web_hub # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
@@ -49,13 +49,6 @@ class ChatGroupConfig(BaseModel):
class ChatConfig(BaseModel):
total_enable: bool = Field(default=True, alias="群聊学习总开关")
enable_web: bool = Field(default=True, alias="启用后台管理")
web_username: str = Field(default="chat", alias="后台管理用户名")
web_password: str = Field(default="admin", alias="后台管理密码")
web_secret_key: str = Field(
default="49c294d32f69b732ef6447c18379451ce1738922a75cd1d4812ef150318a2ed0",
alias="后台管理token密钥",
)
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
@@ -118,7 +111,7 @@ def register_config() -> None:
"""把顶层 ChatConfig 顶层字段接入统一 Web 配置标准(/hub 可读可改)。
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json(避免与自有后台互相覆盖)。
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。
"""
from hexi.config_standard import register_config_items
@@ -131,10 +124,6 @@ def register_config() -> None:
fields = [
{"key": "total_enable", "label": "群聊学习总开关", "type": "bool"},
{"key": "enable_web", "label": "启用后台管理", "type": "bool"},
{"key": "web_username", "label": "后台管理用户名", "type": "string"},
{"key": "web_password", "label": "后台管理密码", "type": "password", "secret": True},
{"key": "web_secret_key", "label": "后台签名密钥", "type": "password", "secret": True},
{"key": "ban_words", "label": "全局屏蔽词", "type": "text", "item_type": "str"},
{"key": "ban_users", "label": "全局屏蔽用户", "type": "text", "item_type": "int"},
{"key": "KEYWORDS_SIZE", "label": "单句关键词分词数量", "type": "int"},
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Nonebot-Plugin-Learning-Chat 控制台" />
<title>Learning-Chat 控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
{
"name": "learning-chat-admin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.5.0",
"element-plus": "^2.14.3",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue-tsc": "^1.8.19"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

@@ -1,146 +0,0 @@
<template>
<el-config-provider>
<!-- 登录页: 无框架布局 -->
<RouterView v-if="!requiresAuth" />
<!-- 主界面: 侧边栏 + 顶栏 + 内容区 -->
<el-container v-else class="app-container">
<el-aside width="220px" class="app-aside">
<div class="aside-title">Learning-Chat 控制台</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
</el-menu-item>
<el-menu-item index="/messages">
<el-icon><ChatDotRound /></el-icon>
<span>群聊消息</span>
</el-menu-item>
<el-menu-item index="/contexts">
<el-icon><Collection /></el-icon>
<span>学习内容</span>
</el-menu-item>
<el-menu-item index="/answers">
<el-icon><ChatLineRound /></el-icon>
<span>回复内容</span>
</el-menu-item>
<el-menu-item index="/blacklist">
<el-icon><CircleClose /></el-icon>
<span>禁用列表</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container class="app-body">
<el-header class="app-header" height="60px">
<span class="header-title">Nonebot-Plugin-Learning-Chat</span>
<div class="header-right">
<el-tag size="small" type="primary" effect="plain">{{ authStore.username }}</el-tag>
<el-button size="small" type="danger" plain @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
</el-container>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 依据路由而非登录态切换布局, 避免登录瞬间闪现后台框架
const requiresAuth = computed(() => route.meta.requiresAuth !== false);
// token 到期自动登出(守卫已拦截"已过期"情况, 这里覆盖"使用中到期")
onMounted(() => {
authStore.scheduleAutoLogout(() => {
ElMessage.warning('登录已过期,请重新登录');
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
});
});
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消',
});
} catch {
return; // 用户取消
}
authStore.logout();
router.push('/login');
};
</script>
<style scoped>
.app-container {
height: 100vh;
}
.app-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.aside-title {
height: 60px;
line-height: 60px;
padding: 0 20px;
font-size: 15px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f2f5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aside-menu {
flex: 1;
border-right: none;
}
.app-body {
min-width: 0;
}
.app-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-main {
background: #f5f7fa;
overflow-y: auto;
}
</style>
@@ -1,204 +0,0 @@
import axios, { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
import { useAuthStore } from '@/stores/auth';
import type {
ApiResponse,
BlacklistItem,
ChatAnswer,
ChatContext,
ChatMessage,
GlobalConfig,
GroupConfig,
GroupInfo,
LoginResult,
PaginatedData,
} from '@/types';
/** 带状态码的请求错误 */
export class ApiError extends Error {
status: number;
/** 认证失效类错误: 已由全局拦截器提示并跳转登录, 视图层不再重复弹窗 */
authHandled = false;
constructor(message: string, status = 0) {
super(message);
this.status = status;
}
}
let redirectingToLogin = false;
/** 认证失效: 清除登录态并跳转登录页(避免并发失败时重复跳转) */
function handleUnauthorized() {
const authStore = useAuthStore();
authStore.logout();
if (router.currentRoute.value.path !== '/login' && !redirectingToLogin) {
redirectingToLogin = true;
ElMessage.warning('登录已失效,请重新登录');
router.push('/login').finally(() => {
redirectingToLogin = false;
});
}
}
class ApiClient {
private axiosInstance;
constructor() {
this.axiosInstance = axios.create({
baseURL: '/learning_chat/api',
timeout: 30000,
});
// 请求: 附加 token 头(后端契约: authentication() 读取 token header)
this.axiosInstance.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.token = authStore.token;
}
return config;
});
// 响应: 三态归一化
// 1. {"status": 0, "msg", "data"} → 成功, 返回整个信封(data 由调用方取)
// 2. {"status": !=0, "msg"} → 业务失败(HTTP 200 内)
// 3. {"detail": ...}(HTTP 400/422)→ 认证失效/参数错误
// 4. 裸 dict(全局/分群配置 GET)→ 直接返回
this.axiosInstance.interceptors.response.use(
(response) => {
const data = response.data;
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (typeof data.detail !== 'undefined') {
handleUnauthorized();
const err = new ApiError(String(data.detail), response.status);
err.authHandled = true;
throw err;
}
if (typeof data.status === 'number') {
if (data.status === 0) return data;
throw new ApiError(String(data.msg || '操作失败'), data.status);
}
}
return data;
},
(error: AxiosError) => {
const status = error.response?.status;
const data = error.response?.data as { detail?: unknown } | undefined;
const isAuthError = status === 400 || status === 422;
// 后端认证失效契约为 HTTP 400/422 + {"detail"}
if (isAuthError) {
handleUnauthorized();
}
const detail = data?.detail;
const message =
typeof detail === 'string'
? detail
: status === 0
? '无法连接到服务器'
: error.message || '网络请求失败';
const apiError = new ApiError(message, status ?? 0);
apiError.authHandled = isAuthError;
return Promise.reject(apiError);
}
);
}
/** 登录 */
async login(username: string, password: string): Promise<ApiResponse<LoginResult>> {
return this.axiosInstance.post('/login', { username, password });
}
/** 获取群列表 */
async getGroupList(): Promise<ApiResponse<{ group_list: GroupInfo[] }>> {
return this.axiosInstance.get('/get_group_list');
}
/** 获取全局配置(裸 dict) */
async getGlobalConfig(): Promise<GlobalConfig> {
return this.axiosInstance.get('/chat_global_config');
}
/** 保存全局配置 */
async saveGlobalConfig(data: GlobalConfig): Promise<ApiResponse> {
return this.axiosInstance.post('/chat_global_config', data);
}
/** 获取分群配置(裸 dict, 概率为百分比) */
async getGroupConfig(groupId: number | string): Promise<GroupConfig> {
return this.axiosInstance.get(`/chat_group_config?group_id=${groupId}`);
}
/** 保存分群配置(groupId 传 'all' 表示保存至所有群) */
async saveGroupConfig(groupId: number | string, data: GroupConfig): Promise<ApiResponse> {
return this.axiosInstance.post(`/chat_group_config?group_id=${groupId}`, data);
}
/** 获取聊天消息 */
async getMessages(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
group_id?: string;
user_id?: string;
message?: string;
}): Promise<ApiResponse<PaginatedData<ChatMessage>>> {
return this.axiosInstance.get('/get_chat_messages', { params });
}
/** 获取学习内容 */
async getContexts(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatContext>>> {
return this.axiosInstance.get('/get_chat_contexts', { params });
}
/** 获取回复 */
async getAnswers(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
context_id?: number;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatAnswer>>> {
return this.axiosInstance.get('/get_chat_answers', { params });
}
/** 获取禁用列表 */
async getBlacklist(params: {
page?: number;
perPage?: number;
keywords?: string;
bans?: string;
}): Promise<ApiResponse<PaginatedData<BlacklistItem>>> {
return this.axiosInstance.get('/get_chat_blacklist', { params });
}
/** 删除(type: message | context | answer | blacklist) */
async deleteChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.delete(`/delete_chat?type=${type}&id=${id}`);
}
/** 禁用(type: message | context | answer) */
async banChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.put(`/ban_chat?type=${type}&id=${id}`);
}
/** 批量删除/取消禁用(type: answer | blacklist | context | message, id 可选) */
async deleteAll(type: string, id?: number): Promise<ApiResponse> {
let url = `/delete_all?type=${type}`;
if (id !== undefined) {
url += `&id=${id}`;
}
return this.axiosInstance.put(url);
}
}
export default new ApiClient();
@@ -1,54 +0,0 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="full-content">{{ content }}</div>
<template #footer>
<el-button type="primary" plain @click="copyContent">复制内容</el-button>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
title?: string;
content?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const copyContent = async () => {
try {
await navigator.clipboard.writeText(props.content || '');
ElMessage.success('已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动选择复制');
}
};
</script>
<style scoped>
.full-content {
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.7;
color: #303133;
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
user-select: text;
}
</style>
@@ -1,22 +0,0 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import router from '@/router';
import App from '@/App.vue';
import '@/style.css';
// 移除旧版「API 地址设置」功能残留(同源架构下该功能已废弃, 写死的地址会导致跨域失败)
localStorage.removeItem('API_BASE_URL');
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');
@@ -1,105 +0,0 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
// 懒加载视图
const Login = () => import('@/views/Login.vue');
const Config = () => import('@/views/Config.vue');
const Messages = () => import('@/views/Messages.vue');
const Contexts = () => import('@/views/Contexts.vue');
const Answers = () => import('@/views/Answers.vue');
const Blacklist = () => import('@/views/Blacklist.vue');
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: () => {
const authStore = useAuthStore();
return authStore.isAuthenticated ? '/config' : '/login';
},
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/config',
name: 'Config',
component: Config,
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'Messages',
component: Messages,
meta: { requiresAuth: true },
},
{
path: '/contexts',
name: 'Contexts',
component: Contexts,
meta: { requiresAuth: true },
},
{
path: '/answers',
name: 'Answers',
component: Answers,
meta: { requiresAuth: true },
},
{
path: '/blacklist',
name: 'Blacklist',
component: Blacklist,
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
meta: { requiresAuth: false },
},
];
const router = createRouter({
// base 与 vite.config.ts 的 base 保持一致, 否则 SPA 导航会产生不带前缀的根路径 URL, 刷新即 404
history: createWebHistory('/learning_chat/'),
routes,
});
// 全局路由守卫
router.beforeEach((to, _from) => {
const authStore = useAuthStore();
// token 已过期(按 JWT exp 主动检测) -> 立即登出跳登录页
if (authStore.token) {
const exp = authStore.getTokenExpiry(authStore.token);
if (exp !== null && exp * 1000 <= Date.now()) {
authStore.logout();
if (to.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录');
return { path: '/login', query: { redirect: to.fullPath } };
}
}
}
const requiresAuth = to.meta.requiresAuth;
// 需要登录但未认证 -> 跳登录页
if (requiresAuth && !authStore.isAuthenticated) {
return {
path: '/login',
query: { redirect: to.fullPath },
};
}
// 已登录但访问登录页 -> 跳配置页
if (to.path === '/login' && authStore.isAuthenticated) {
return '/config';
}
// 其他情况放行
return true;
});
export default router;
@@ -1,81 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
// 初始化时从 localStorage 读取
const token = ref<string>(localStorage.getItem('token') || '');
const username = ref<string>(localStorage.getItem('username') || '');
const isAuthenticated = computed(() => !!token.value);
let expireTimer: ReturnType<typeof setTimeout> | null = null;
/** 解析 JWT payload 中的 exp(unix 秒); 非 JWT 或解析失败返回 null */
const getTokenExpiry = (t: string): number | null => {
try {
const payload = t.split('.')[1];
if (!payload) return null;
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
);
return typeof json.exp === 'number' ? json.exp : null;
} catch {
return null;
}
};
/**
* 注册到期自动登出:
* - token 已过期 → 立即 logout 并返回 true
* - 未过期 → 在 exp 时刻触发 onExpired 回调
*/
const scheduleAutoLogout = (onExpired?: () => void): boolean => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
if (!token.value) return false;
const exp = getTokenExpiry(token.value);
if (exp === null) return false;
const remainingMs = exp * 1000 - Date.now();
if (remainingMs <= 0) {
logout();
return true;
}
expireTimer = setTimeout(() => {
logout();
onExpired?.();
}, remainingMs);
return false;
};
const setAuth = (newToken: string, newUsername: string) => {
token.value = newToken;
username.value = newUsername;
localStorage.setItem('token', newToken);
localStorage.setItem('username', newUsername);
// 登录即武装到期定时器
scheduleAutoLogout();
};
const logout = () => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
token.value = '';
username.value = '';
localStorage.removeItem('token');
localStorage.removeItem('username');
};
return {
token,
username,
isAuthenticated,
getTokenExpiry,
scheduleAutoLogout,
setAuth,
logout,
};
});
@@ -1,79 +0,0 @@
/* 全局样式 — Element Plus 自带 reset, 这里只保留主题与少量通用类 */
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #f5f7fa;
color: #303133;
}
/* 页面级白色卡片容器(列表页) */
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 页面顶部说明条 */
.page-tip {
margin-bottom: 16px;
}
/* 搜索栏与操作行 */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toolbar .el-input {
width: 160px;
}
.toolbar .toolbar-wide {
width: 240px;
}
/* 分页容器 */
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* 表格内可点击查看完整内容的长文本单元格 */
.clickable-cell {
cursor: pointer;
color: #606266;
transition: color 0.2s;
}
.clickable-cell:hover {
color: var(--el-color-primary);
text-decoration: underline;
}
/* 单行省略(配合可点击单元格使用) */
.cell-ellipsis {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 表单辅助说明文字 */
.help-text {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 4px;
}
@@ -1,97 +0,0 @@
/**
* 与后端 web_api.py 契约一致的类型定义
*/
/** 后端统一信封(列表/操作类接口); 配置 GET 返回裸 dict, 认证错误返回 {"detail"} */
export interface ApiResponse<T = unknown> {
status: number;
msg: string;
data?: T;
}
export interface PaginatedData<T> {
items: T[];
total: number;
}
export interface LoginResult {
token: string;
}
export interface GroupInfo {
label: string;
value: number;
}
export interface ChatMessage {
id: number;
message_id: string | number;
group_id: number;
user_id: number;
raw_message: string;
message?: string;
/** unix 秒整数 */
time: number;
}
export interface ChatContext {
id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
}
export interface ChatAnswer {
id: number;
group_id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
context_id: number;
/** 旧数据可能为 null */
messages: Array<{ msg: string }> | null;
}
export interface BlacklistItem {
id: number;
keywords: string;
bans: string;
global_ban?: boolean;
ban_group_id?: number[];
}
export interface GlobalConfig {
total_enable: boolean;
enable_web: boolean;
web_username: string;
web_password: string;
web_secret_key: string;
KEYWORDS_SIZE: number;
cross_group_threshold: number;
learn_max_count: number;
ban_words: string[];
ban_users: string[];
dictionary: string[];
}
export interface GroupConfig {
enable: boolean;
answer_threshold: number;
answer_threshold_weights: number[];
repeat_threshold: number;
/** 0-100 百分比(后端 GET ×100 / POST ÷100 契约) */
break_probability: number;
speak_enable: boolean;
speak_threshold: number;
speak_min_interval: number;
/** 0-100 百分比 */
speak_continuously_probability: number;
speak_continuously_max_len: number;
/** 0-100 百分比 */
speak_poke_probability: number;
ban_words: string[];
ban_users: string[];
member_list: GroupInfo[];
}
@@ -1,14 +0,0 @@
/** 后端 time 为 unix 秒整数, 格式化为本地时间(兼容毫秒/字符串) */
export function formatTime(time: number | string | null | undefined): string {
if (time === null || time === undefined || time === '') return '';
let t = Number(time);
if (Number.isNaN(t)) return String(time);
if (t < 1e12) t *= 1000; // 秒 → 毫秒
return new Date(t).toLocaleString('zh-CN');
}
/** 从错误对象提取可读消息(ApiError/普通 Error/未知值) */
export function errorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message) return err.message;
return fallback;
}
@@ -1,12 +0,0 @@
import { ElMessage } from 'element-plus';
import { ApiError } from '@/api/client';
import { errorMessage } from './format';
/**
* 展示错误提示。
* 认证失效类错误已由全局拦截器统一提示并跳转登录, 这里跳过, 避免重复弹窗。
*/
export function showError(err: unknown, fallback: string): void {
if (err instanceof ApiError && err.authHandled) return;
ElMessage.error(errorMessage(err, fallback));
}
@@ -1,264 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人已学习到的所有回复。</p>
<p>点击「禁用」可以将该回复进行禁用;点击「删除」可以删除该回复。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="answers" stripe border @sort-change="handleSortChange">
<el-table-column type="expand">
<template #default="{ row }">
<div class="answers-detail">
<div v-for="(m, i) in (row.messages || [])" :key="i" class="answer-msg">
{{ m.msg }}
</div>
<el-empty
v-if="!row.messages || row.messages.length === 0"
description="无消息内容"
:image-size="50"
/>
</div>
</template>
</el-table-column>
<el-table-column label="内容/关键词" min-width="180" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`回复 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="次数" width="90" sortable="custom" />
<el-table-column label="消息预览" min-width="240">
<template #default="{ row }">
<div class="msg-preview clickable-cell" @click="showDetail(`完整消息 - ${row.keywords}`, (row.messages || []).map((m) => m.msg).join('\n'))">
<div v-for="(m, i) in (row.messages || []).slice(0, 2)" :key="i" class="msg-preview-line">
{{ m.msg }}
</div>
<div v-if="(row.messages || []).length > 2" class="msg-preview-more">
+{{ row.messages!.length - 2 }} 更多(点击查看全部)
</div>
<span v-if="!row.messages || row.messages.length === 0" class="msg-preview-empty">无消息内容</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无回复数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchAnswers"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer } from '@/types';
const answers = ref<ChatAnswer[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('count');
const sortOrder = ref<'asc' | 'desc'>('desc');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchAnswers = async () => {
loading.value = true;
try {
const res = await apiClient.getAnswers({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
answers.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchAnswers();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchAnswers();
};
const handleDelete = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
fetchAnswers();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有回复吗?这是不可逆的操作!',
'删除所有回复',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('answer');
ElMessage.success('已删除所有回复');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchAnswers();
</script>
<style scoped>
.answers-detail {
padding: 8px 24px;
}
.answer-msg {
padding: 6px 10px;
background: #f5f7fa;
border-radius: 4px;
border-left: 3px solid #67c23a;
margin-bottom: 6px;
font-size: 13px;
color: #606266;
word-break: break-all;
}
.answer-msg:last-child {
margin-bottom: 0;
}
.msg-preview-line {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-preview-more {
font-size: 12px;
color: #409eff;
}
.msg-preview-empty {
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -1,170 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人被禁用的内容/关键词。</p>
<p>可以取消禁用,使其能够重新继续学习;不能在此添加禁用,只能在配置中添加屏蔽词来达到禁用效果。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
@keyup.enter="handleSearch"
/>
<el-input
v-model="searchBans"
placeholder="搜索禁用群"
clearable
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">取消所有禁用</el-button>
</div>
<el-table v-loading="loading" :data="blacklist" stripe border>
<el-table-column label="内容/关键词" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`禁用项 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column label="已禁用的群" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.global_ban" type="danger" effect="plain">全局禁用</el-tag>
<el-tag v-else-if="row.bans" type="info" effect="plain">{{ row.bans }}</el-tag>
<span v-else class="bans-empty">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="handleUnban(row)">取消禁用</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无禁用项目" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchBlacklist"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { showError } from '@/utils/notify';
import type { BlacklistItem } from '@/types';
const blacklist = ref<BlacklistItem[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const searchBans = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchBlacklist = async () => {
loading.value = true;
try {
const res = await apiClient.getBlacklist({
page: page.value,
perPage,
keywords: searchKeywords.value,
bans: searchBans.value,
});
blacklist.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取禁用列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchBlacklist();
};
const handleUnban = async (row: BlacklistItem) => {
try {
await ElMessageBox.confirm(`确定要取消禁用 "${row.keywords}" 吗?`, '取消禁用', {
type: 'warning',
confirmButtonText: '取消禁用',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteChat('blacklist', row.id);
ElMessage.success('已取消禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm('确定要取消所有禁用吗?', '取消所有禁用', {
type: 'warning',
confirmButtonText: '全部取消',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteAll('blacklist');
ElMessage.success('已取消所有禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
// 初始加载
fetchBlacklist();
</script>
<style scoped>
.bans-empty {
color: #c0c4cc;
}
</style>
@@ -1,586 +0,0 @@
<template>
<div class="config-container">
<el-tabs v-model="activeTab">
<!-- ==================== 全局配置 ==================== -->
<el-tab-pane label="全局配置" name="global">
<div v-loading="loading" class="config-body">
<template v-if="globalConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习总开关">
<el-switch
v-model="globalConfig.total_enable"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">关闭后,全局都将不会再学习和回复</div>
</el-form-item>
<el-form-item label="后台管理总开关">
<el-switch
v-model="globalConfig.enable_web"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">是否开启本后台管理</div>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="config-card">
<template #header>认证设置</template>
<el-form label-position="top">
<el-form-item label="后台管理用户名">
<el-input v-model="globalConfig.web_username" placeholder="e.g. chat" />
<div class="help-text">登录本后台管理所需要的用户名</div>
</el-form-item>
<el-form-item label="后台管理密码">
<el-input
v-model="globalConfig.web_password"
type="password"
show-password
placeholder="输入密码"
/>
<div class="help-text">登录本后台管理所需要的密码</div>
</el-form-item>
<el-form-item label="后台管理Token密钥">
<el-input v-model="globalConfig.web_secret_key" placeholder="输入密钥" />
<div class="help-text">用于加密验证Token的密钥</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>学习参数</template>
<el-form label-position="top">
<el-form-item label="单句关键词数量">
<el-input-number v-model="globalConfig.KEYWORDS_SIZE" :min="2" controls-position="right" />
<div class="help-text">单句语句标签数量,建议保持默认为3</div>
</el-form-item>
<el-form-item label="跨群回复阈值">
<el-input-number v-model="globalConfig.cross_group_threshold" :min="1" controls-position="right" />
<div class="help-text">当学习到的一种回复在N个群都有,就变为全局回复</div>
</el-form-item>
<el-form-item label="最高学习次数">
<el-input-number v-model="globalConfig.learn_max_count" :min="2" controls-position="right" />
<div class="help-text">值越高,这个回复就会越容易进行回复</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="全局屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="banWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_words, banWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="全局屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(user, i) in globalConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_users.splice(i, 1)"
>
{{ user }}
</el-tag>
<el-input
v-model="banUserInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_users, banUserInput)"
/>
</div>
<div class="help-text">和这些用户有关的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="自定义词典">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.dictionary"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.dictionary.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="dictionaryInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.dictionary, dictionaryInput)"
/>
</div>
<div class="help-text">添加自定义词语,提高学习的准确性</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGlobal">保存</el-button>
<el-button @click="resetGlobal">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
<!-- ==================== 分群配置 ==================== -->
<el-tab-pane label="分群配置" name="group">
<div class="page-card group-select-row">
<span class="group-select-label">选择群:</span>
<el-select
v-model="selectedGroupId"
placeholder="-- 请选择群 --"
clearable
style="flex: 1"
@change="handleGroupChange"
>
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</div>
<div v-if="selectedGroupId" v-loading="groupLoading" class="config-body">
<template v-if="groupConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习开关">
<el-switch v-model="groupConfig.enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">针对该群的学习开关</div>
</el-form-item>
<el-form-item v-if="groupConfig.enable" label="回复阈值">
<el-input-number v-model="groupConfig.answer_threshold" :min="2" controls-position="right" />
<div class="help-text">学习成功所需要的次数,值越低学得越快</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>回复阈值权重</template>
<el-form label-position="top">
<el-form-item label="设置权重(%)">
<div class="weight-list">
<div v-for="(weight, i) in groupConfig.answer_threshold_weights" :key="i" class="weight-row">
<el-input-number v-model="groupConfig.answer_threshold_weights[i]" :min="1" :max="100" controls-position="right" />
<el-button size="small" type="danger" plain @click="removeWeight(i)">删除</el-button>
</div>
<el-button size="small" type="success" plain @click="addWeight">+ 添加权重</el-button>
</div>
<div class="help-text">影响回复阈值的计算方式</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>复读设置</template>
<el-form label-position="top">
<el-form-item label="复读阈值">
<el-input-number v-model="groupConfig.repeat_threshold" :min="2" controls-position="right" />
<div class="help-text">N个人复读后机器人就会跟着复读</div>
</el-form-item>
<el-form-item label="打断复读概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.break_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">达到复读阈值时,打断复读的概率</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>主动发言设置</template>
<el-form label-position="top">
<el-form-item label="主动发言开关">
<el-switch v-model="groupConfig.speak_enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">是否允许机器人在该群主动发言</div>
</el-form-item>
<template v-if="groupConfig.speak_enable">
<el-form-item label="主动发言阈值">
<el-input-number v-model="groupConfig.speak_threshold" :min="0" controls-position="right" />
<div class="help-text">值越低,主动发言的可能性越高</div>
</el-form-item>
<el-form-item label="主动发言最小间隔">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_min_interval" :min="0" controls-position="right" />
<span class="suffix-text">秒</span>
</div>
<div class="help-text">进行主动发言的最小时间间隔</div>
</el-form-item>
<el-form-item label="连续主动发言概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_continuously_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">触发主动发言时,连续发言的概率</div>
</el-form-item>
<el-form-item label="最大连续发言句数">
<el-input-number v-model="groupConfig.speak_continuously_max_len" :min="1" controls-position="right" />
<div class="help-text">连续主动发言的最大句数</div>
</el-form-item>
<el-form-item label="戳一戳概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_poke_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">主动发言时附带戳一戳的概率</div>
</el-form-item>
</template>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in groupConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="groupBanWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(groupConfig.ban_words, groupBanWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习</div>
</el-form-item>
<el-form-item label="屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(userId, i) in groupConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_users.splice(i, 1)"
>
{{ formatUserId(userId) }}
</el-tag>
<el-select
v-model="pendingMember"
size="small"
class="member-select"
placeholder="-- 选择用户添加 --"
clearable
@change="addMember"
>
<el-option v-for="m in groupConfig.member_list" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</div>
<div class="help-text">和这些用户有关的消息不会学习</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
<el-button type="success" plain :loading="saving" @click="saveGroupAll">保存至所有群</el-button>
<el-button @click="resetGroup">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, type Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { showError } from '@/utils/notify';
import type { GlobalConfig, GroupConfig, GroupInfo } from '@/types';
const activeTab = ref<'global' | 'group'>('global');
// ==================== 全局配置 ====================
const loading = ref(false);
const saving = ref(false);
const globalConfig = ref<GlobalConfig | null>(null);
let globalBackup: GlobalConfig | null = null;
const banWordInput = ref('');
const banUserInput = ref('');
const dictionaryInput = ref('');
const fetchGlobalConfig = async () => {
loading.value = true;
try {
globalConfig.value = await apiClient.getGlobalConfig();
// 深拷贝快照, 重置时恢复(修复原浅拷贝 bug)
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
} catch (err) {
showError(err, '获取全局配置失败');
} finally {
loading.value = false;
}
};
const saveGlobal = async () => {
if (!globalConfig.value) return;
saving.value = true;
try {
await apiClient.saveGlobalConfig(globalConfig.value);
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGlobal = () => {
if (globalBackup) {
globalConfig.value = JSON.parse(JSON.stringify(globalBackup));
}
};
// ==================== 分群配置 ====================
const groups = ref<GroupInfo[]>([]);
const selectedGroupId = ref<number | null>(null);
const groupLoading = ref(false);
const groupConfig = ref<GroupConfig | null>(null);
let groupBackup: GroupConfig | null = null;
const groupBanWordInput = ref('');
const pendingMember = ref<number | null>(null);
const fetchGroupList = async () => {
try {
const res = await apiClient.getGroupList();
groups.value = res.data!.group_list;
} catch (err) {
showError(err, '获取群列表失败');
}
};
const handleGroupChange = (value: number | null) => {
if (value === null) {
groupConfig.value = null;
return;
}
fetchGroupConfig(value);
};
const fetchGroupConfig = async (groupId: number) => {
groupLoading.value = true;
try {
groupConfig.value = await apiClient.getGroupConfig(groupId);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
} catch (err) {
groupConfig.value = null;
showError(err, '获取分群配置失败');
} finally {
groupLoading.value = false;
}
};
const saveGroup = async () => {
if (!groupConfig.value || selectedGroupId.value === null) return;
saving.value = true;
try {
await apiClient.saveGroupConfig(selectedGroupId.value, groupConfig.value);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const saveGroupAll = async () => {
if (!groupConfig.value) return;
try {
await ElMessageBox.confirm('确定要将当前配置保存至所有群吗?', '保存至所有群', {
type: 'warning',
confirmButtonText: '保存',
cancelButtonText: '取消',
});
} catch {
return;
}
saving.value = true;
try {
await apiClient.saveGroupConfig('all', groupConfig.value);
ElMessage.success('已保存至所有群');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGroup = () => {
if (groupBackup) {
groupConfig.value = JSON.parse(JSON.stringify(groupBackup));
}
};
// ==================== 通用 ====================
/** 动态标签输入: 回车添加, 重复值不添加 */
const addTag = (list: string[], input: Ref<string>) => {
const value = input.value.trim();
if (value && !list.includes(value)) {
list.push(value);
}
input.value = '';
};
const addWeight = () => {
if (!groupConfig.value) return;
groupConfig.value.answer_threshold_weights.push(25);
};
const removeWeight = (index: number) => {
if (!groupConfig.value) return;
if (groupConfig.value.answer_threshold_weights.length > 1) {
groupConfig.value.answer_threshold_weights.splice(index, 1);
}
};
const addMember = (value: number | null) => {
if (!groupConfig.value || value === null) return;
const v = String(value);
if (!groupConfig.value.ban_users.includes(v)) {
groupConfig.value.ban_users.push(v);
}
pendingMember.value = null;
};
const formatUserId = (userId: string | number) => {
const member = groupConfig.value?.member_list.find(
(m) => m.value.toString() === userId.toString()
);
return member?.label || String(userId);
};
onMounted(() => {
fetchGlobalConfig();
fetchGroupList();
});
</script>
<style scoped>
.config-container {
max-width: 1000px;
margin: 0 auto;
}
.config-body {
min-height: 100px;
}
.config-card {
margin-bottom: 16px;
border-radius: 8px;
}
.config-card .el-form {
max-width: 520px;
}
.config-card :deep(.el-input-number) {
width: 100%;
}
.suffix-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.suffix-text {
color: #909399;
font-size: 13px;
flex-shrink: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.tag-input {
width: 200px;
}
.member-select {
width: 220px;
}
.weight-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.weight-row {
display: flex;
align-items: center;
gap: 8px;
}
.weight-row .el-input-number {
width: 180px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.group-select-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 16px 20px;
}
.group-select-label {
font-weight: 500;
color: #303133;
flex-shrink: 0;
}
</style>
@@ -1,280 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人所学习的内容。</p>
<p>点击「回复列表」可以查看该条内容已学习到的回复;点击「禁用」可以禁用该学习;点击「删除」可以删除该学习。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="contexts" stripe border @sort-change="handleSortChange">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="内容/关键词" min-width="240" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`学习内容 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="已学次数" width="110" sortable="custom" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="showAnswers(row)">回复列表</el-button>
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无学习内容" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchContexts"
/>
</div>
</div>
<!-- 回复列表弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="`回复列表 - ${currentContext?.keywords ?? ''}`"
width="640px"
destroy-on-close
@closed="dialogVisible = false; currentContext = null; contextAnswers = []"
>
<div v-loading="answersLoading">
<el-table v-if="contextAnswers.length" :data="contextAnswers" size="small" stripe border>
<el-table-column prop="keywords" label="回复内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBanAnswer(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDeleteAnswer(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!answersLoading" description="暂无回复" :image-size="80" />
</div>
</el-dialog>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer, ChatContext } from '@/types';
const contexts = ref<ChatContext[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('time');
const sortOrder = ref<'asc' | 'desc'>('desc');
const dialogVisible = ref(false);
const currentContext = ref<ChatContext | null>(null);
const contextAnswers = ref<ChatAnswer[]>([]);
const answersLoading = ref(false);
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchContexts = async () => {
loading.value = true;
try {
const res = await apiClient.getContexts({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
contexts.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取学习内容失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchContexts();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchContexts();
};
const handleDelete = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要删除学习内容 "${row.keywords}" 吗?`, '删除学习内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('context', row.id);
ElMessage.success('已删除');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要禁用学习内容 "${row.keywords}" 吗?`, '禁用学习内容', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('context', row.id);
ElMessage.success('已禁用');
fetchContexts();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有学习内容吗?这是不可逆的操作!',
'删除所有学习内容',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('context');
ElMessage.success('已删除所有学习内容');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const showAnswers = async (row: ChatContext) => {
currentContext.value = row;
contextAnswers.value = [];
dialogVisible.value = true;
answersLoading.value = true;
try {
const res = await apiClient.getAnswers({ context_id: row.id, perPage: 100 });
contextAnswers.value = res.data!.items;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
answersLoading.value = false;
}
};
const handleDeleteAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '删除失败');
}
};
const handleBanAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '禁用失败');
}
};
// 初始加载
fetchContexts();
</script>
@@ -1,159 +0,0 @@
<template>
<div class="login-wrapper">
<el-card class="login-card" shadow="always">
<div class="logo-container">
<img
:src="LOGO_URL"
alt="Nonebot-Plugin-Learning-Chat"
class="logo"
@error="logoFailed = true"
/>
</div>
<h1 class="app-title">Nonebot-Plugin-Learning-Chat 控制台</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:disabled="loading"
autofocus
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
:disabled="loading"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="login-error"
/>
<el-button
type="primary"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import apiClient from '@/api/client';
const LOGO_URL =
'https://s3.sansenhoshi.top/s-file-trans/%E5%9B%BE%E7%89%87/icon/123.jpg';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const formRef = ref<FormInstance>();
const loading = ref(false);
const error = ref('');
const logoFailed = ref(false);
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const handleLogin = async () => {
error.value = '';
if (!formRef.value) return;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const res = await apiClient.login(form.username.trim(), form.password);
authStore.setAuth(res.data!.token, form.username.trim());
// 登录后跳回被拦截前的页面(如 token 过期时重定向来的路径)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.push(redirect);
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败,请重试';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
padding: 12px 8px;
}
.logo-container {
text-align: center;
margin-bottom: 20px;
}
.logo {
width: 90px;
height: 90px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.app-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 28px;
line-height: 1.4;
}
.login-error {
margin-bottom: 16px;
}
.login-button {
width: 100%;
margin-top: 4px;
}
</style>
@@ -1,188 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人收到的聊天记录。</p>
<p>点击「禁用」可以将某条聊天记录进行禁用;点击「删除」可以删除某条记录。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input v-model="searchGroupId" placeholder="群ID" clearable />
<el-input v-model="searchUserId" placeholder="用户ID" clearable />
<el-input
v-model="searchMessage"
placeholder="消息内容"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="messages" stripe border>
<el-table-column prop="message_id" label="消息ID" width="130" show-overflow-tooltip />
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="user_id" label="用户ID" width="110" />
<el-table-column label="消息" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.raw_message || row.message"
@click="showDetail(`消息 #${row.message_id}`, row.raw_message || row.message)"
>
{{ row.raw_message || row.message }}
</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无消息数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchMessages"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatMessage } from '@/types';
const messages = ref<ChatMessage[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchGroupId = ref('');
const searchUserId = ref('');
const searchMessage = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchMessages = async () => {
loading.value = true;
try {
const res = await apiClient.getMessages({
page: page.value,
perPage,
group_id: searchGroupId.value,
user_id: searchUserId.value,
message: searchMessage.value,
});
messages.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取消息失败');
} finally {
loading.value = false;
}
};
// 搜索必须重置到第 1 页, 否则可能停留在超范围的页码上
const handleSearch = () => {
page.value = 1;
fetchMessages();
};
const handleDelete = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要删除消息 "${row.raw_message || row.message}" 吗?`, '删除消息', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('message', row.id);
ElMessage.success('已删除');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要禁用这条消息吗?`, '禁用消息', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('message', row.id);
ElMessage.success('已禁用');
fetchMessages();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有消息吗?这是不可逆的操作!',
'删除所有消息',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('message');
ElMessage.success('已删除所有消息');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchMessages();
</script>
@@ -1,5 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
@@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
@@ -1,36 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* 路径别名 */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Vue支持 */
"types": ["vite/client"]
},
"include": ["src", "src/**/*.ts", "src/**/*.vue", "src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -1,26 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
// 构建产物由 bot 在 /learning_chat 路径下提供
base: '/learning_chat/',
resolve: {
alias: {
'@': path.resolve(process.cwd(), 'src'),
},
},
server: {
port: 5173,
open: false,
host: '0.0.0.0',
// 开发模式下 API 请求转发到 bot 端口
proxy: {
'/learning_chat/api': {
target: 'http://localhost:39697',
changeOrigin: true,
},
},
},
})
@@ -1,369 +0,0 @@
import datetime
from typing import Optional, Union
from fastapi import FastAPI
from fastapi import Header, HTTPException, Depends
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
from jose import jwt
from nonebot import get_app, get_adapter, logger
from nonebot.adapters.onebot.v11 import Adapter
from pydantic import BaseModel
from sqlalchemy import select, delete, update, func
try:
import jieba_fast as jieba
except ImportError:
import jieba
from nonebot_plugin_orm import get_session
from .services.learn import LearningChat
from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList
from .config import config_manager, driver
from .web_frontend import ensure_frontend_ready, mount_frontend
def authentication():
def inner(token: Optional[str] = Header(...)):
try:
payload = jwt.decode(token, config_manager.config.web_secret_key, algorithms="HS256")
if (
not (username := payload.get("username"))
or username != config_manager.config.web_username
):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class UserModel(BaseModel):
username: str
password: str
@driver.on_startup
async def init_web():
if not config_manager.config.enable_web:
return
if not await ensure_frontend_ready():
logger.warning("群聊学习 | 前端构建失败, 管理页面将不可用")
app: FastAPI = get_app()
@app.post("/learning_chat/api/login", response_class=JSONResponse)
async def login(user: UserModel):
if (
user.username != config_manager.config.web_username
or user.password != config_manager.config.web_password
):
return {"status": -100, "msg": "登录失败,请确认用户ID和密码无误"}
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30),
},
config_manager.config.web_secret_key,
algorithm="HS256",
)
return {"status": 0, "msg": "登录成功", "data": {"token": token}}
@app.options("/learning_chat/api/login")
async def options_login():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_group_list", response_class=JSONResponse, dependencies=[authentication()])
async def get_group_list_api():
try:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
group_list = await bot.get_group_list()
group_list = [
{"label": f'{group["group_name"]}({group["group_id"]})', "value": group["group_id"]}
for group in group_list
]
return {"status": 0, "msg": "ok", "data": {"group_list": group_list}}
except ValueError:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
@app.options("/learning_chat/api/get_group_list")
async def options_get_group_list():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_global_config():
# 注意: 不再注入 member_list —— 前端全局配置页不使用(仅分群配置需要,
# 由 chat_group_config 单独拉取单个群成员), 避免串行拉取全部群成员导致接口卡顿
return config_manager.config.dict(exclude={"group_config"})
@app.post("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
async def post_chat_global_config(data: dict):
config_manager.config.update(**data)
config_manager.save()
async with get_session() as session:
await session.execute(
update(ChatContext)
.where(ChatContext.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.execute(
update(ChatAnswer)
.where(ChatAnswer.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.commit()
jieba.load_userdict(config_manager.config.dictionary)
return {"status": 0, "msg": "保存成功"}
@app.options("/learning_chat/api/chat_global_config")
async def options_chat_global_config():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_group_config(group_id: int):
try:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
members = await bot.get_group_member_list(group_id=group_id)
member_list = [
{"label": f'{member["nickname"] or member["card"]}({member["user_id"]})', "value": member["user_id"]}
for member in members
]
config = config_manager.get_group_config(group_id).dict()
config["break_probability"] = config["break_probability"] * 100
config["speak_continuously_probability"] = config["speak_continuously_probability"] * 100
config["speak_poke_probability"] = config["speak_poke_probability"] * 100
config["member_list"] = member_list
return config
except ValueError:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
@app.post("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
async def post_chat_group_config(group_id: Union[int, str], data: dict):
if not data.get("answer_threshold_weights"):
return {"status": 400, "msg": "回复阈值权重不能为空,必须至少有一个数值"}
data["break_probability"] = data["break_probability"] / 100
data["speak_continuously_probability"] = data["speak_continuously_probability"] / 100
data["speak_poke_probability"] = data["speak_poke_probability"] / 100
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
groups = (
[{"group_id": group_id}] if group_id != "all" else await bot.get_group_list()
)
for group in groups:
config = config_manager.get_group_config(int(group["group_id"]))
config.update(**data)
config_manager.config.group_config[int(group["group_id"])] = config
config_manager.save()
return {"status": 0, "msg": "保存成功"}
@app.options("/learning_chat/api/chat_group_config")
async def options_chat_group_config():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_messages", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_messages(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
group_id: Optional[str] = None,
user_id: Optional[str] = None,
message: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatMessage)
if group_id:
stmt = stmt.where(ChatMessage.group_id == int(group_id))
if user_id:
stmt = stmt.where(ChatMessage.user_id == int(user_id))
if message:
stmt = stmt.where(ChatMessage.raw_message.contains(message))
order_col = getattr(ChatMessage, orderBy or "time", ChatMessage.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatMessage.__table__.columns}
for row in items_result.scalars().all()
]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_messages")
async def options_get_chat_messages():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_contexts", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_context(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatContext)
if keywords:
stmt = stmt.where(ChatContext.keywords.contains(keywords))
order_col = getattr(ChatContext, orderBy or "time", ChatContext.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatContext.__table__.columns}
for row in items_result.scalars().all()
]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_contexts")
async def options_get_chat_contexts():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_answers", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_answers(
context_id: Optional[int] = None,
page: int = 1,
perPage: int = 10,
orderBy: str = "count",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatAnswer)
if context_id:
stmt = stmt.where(ChatAnswer.context_id == context_id)
if keywords:
stmt = stmt.where(ChatAnswer.keywords.contains(keywords))
order_col = getattr(ChatAnswer, orderBy or "count", ChatAnswer.count)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatAnswer.__table__.columns}
item["messages"] = [{"msg": m} for m in item["messages"]]
items.append(item)
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_answers")
async def options_get_chat_answers():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_blacklist", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_blacklist(
page: int = 1,
perPage: int = 10,
keywords: Optional[str] = None,
bans: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatBlackList).order_by(ChatBlackList.id.desc())
if keywords:
stmt = stmt.where(ChatBlackList.keywords.contains(keywords))
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
# bans 过滤需要基于全部数据(而非当前页), 故先全量取回再过滤、再分页
items_result = await session.execute(stmt)
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatBlackList.__table__.columns}
ban_ids = item["ban_group_id"] or []
item["bans"] = "全局禁用" if item["global_ban"] else (str(ban_ids[0]) if ban_ids else "")
items.append(item)
if bans:
items = [x for x in items if bans in x["bans"]]
total = len(items)
items = items[(page - 1) * perPage : page * perPage]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_blacklist")
async def options_get_chat_blacklist():
return JSONResponse(content={}, status_code=200)
@app.delete("/learning_chat/api/delete_chat", response_class=JSONResponse, dependencies=[authentication()])
async def delete_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
await session.execute(delete(ChatMessage).where(ChatMessage.id == id))
elif type == "context":
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
await session.execute(delete(ChatContext).where(ChatContext.id == id))
elif type == "answer":
await session.execute(delete(ChatAnswer).where(ChatAnswer.id == id))
elif type == "blacklist":
await session.execute(delete(ChatBlackList).where(ChatBlackList.id == id))
await session.commit()
return {"status": 0, "msg": "删除成功"}
except Exception as e:
return {"status": 500, "msg": f"删除失败,{e}"}
@app.options("/learning_chat/api/delete_chat")
async def options_delete_chat():
return JSONResponse(content={}, status_code=200)
@app.put("/learning_chat/api/ban_chat", response_class=JSONResponse, dependencies=[authentication()])
async def ban_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
result = await session.execute(select(ChatMessage).where(ChatMessage.id == id))
data = result.scalar_one()
elif type == "context":
result = await session.execute(select(ChatContext).where(ChatContext.id == id))
data = result.scalar_one()
else:
result = await session.execute(select(ChatAnswer).where(ChatAnswer.id == id))
data = result.scalar_one()
await LearningChat.add_ban(data)
return {"status": 0, "msg": "禁用成功"}
except Exception as e:
return {"status": 500, "msg": f"禁用失败: {e}"}
@app.options("/learning_chat/api/ban_chat")
async def options_ban_chat():
return JSONResponse(content={}, status_code=200)
@app.put("/learning_chat/api/delete_all", response_class=JSONResponse, dependencies=[authentication()])
async def delete_all(type: str, id: Optional[int] = None):
try:
async with get_session() as session:
if type == "answer":
if id:
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
else:
await session.execute(delete(ChatAnswer))
elif type == "blacklist":
await session.execute(delete(ChatBlackList))
elif type == "context":
await session.execute(delete(ChatContext))
elif type == "message":
await session.execute(delete(ChatMessage))
await session.commit()
return {"status": 0, "msg": "操作成功"}
except Exception as e:
return {"status": 500, "msg": f"操作失败,{e}"}
@app.options("/learning_chat/api/delete_all")
async def options_delete_all():
return JSONResponse(content={}, status_code=200)
# 静态资源挂载放在所有 API 路由之后, 保证 /learning_chat/api/* 优先匹配
mount_frontend(app)
@@ -1,99 +0,0 @@
"""前端管理页面的构建与静态挂载。
方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles
挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。
前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。
"""
import asyncio
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from nonebot import logger
BASE_DIR = Path(__file__).resolve().parent
ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin"
DIST_DIR = ADMIN_DIR / "dist"
# 参与构建新鲜度检查的源文件(不在 src/ 下的)
SRC_FILES = ("vite.config.ts", "index.html", "package.json")
def _dist_is_stale() -> bool:
"""dist/index.html 不存在, 或比任何源码旧时视为需要重建"""
dist_index = DIST_DIR / "index.html"
if not dist_index.exists():
return True
dist_mtime = dist_index.stat().st_mtime
for src_file in (ADMIN_DIR / "src").rglob("*"):
if src_file.is_file() and src_file.stat().st_mtime > dist_mtime:
return True
return any(
(ADMIN_DIR / name).stat().st_mtime > dist_mtime
for name in SRC_FILES
if (ADMIN_DIR / name).exists()
)
async def build_frontend() -> bool:
"""执行 vite 构建, 返回是否成功"""
logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...")
proc = await asyncio.create_subprocess_exec(
"npx.cmd",
"vite",
"build",
cwd=str(ADMIN_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
output, _ = await proc.communicate()
if proc.returncode != 0:
logger.error(
"群聊学习 | 前端构建失败(%s):\n%s",
proc.returncode,
output.decode(errors="ignore")[-3000:],
)
return False
logger.info("群聊学习 | 前端构建完成")
return True
async def ensure_frontend_ready() -> bool:
"""确保构建产物是最新的, 返回是否可用(最新或已成功重建)"""
if not _dist_is_stale():
return True
return await build_frontend()
def mount_frontend(app) -> None:
"""将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。
history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回,
其余路径(如 /login /config 等前端路由)一律回退到 index.html。
"""
if not DIST_DIR.exists():
logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用")
return
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount(
"/learning_chat/assets",
StaticFiles(directory=str(assets_dir)),
name="learning_chat_assets",
)
dist_resolved = DIST_DIR.resolve()
@app.get("/learning_chat/{full_path:path}")
async def learning_chat_spa(full_path: str):
target = (DIST_DIR / full_path).resolve()
if full_path and target.is_file() and target.is_relative_to(dist_resolved):
return FileResponse(target)
# SPA 回退: 前端路由(如 /login /config)全部交给 index.html
return FileResponse(DIST_DIR / "index.html")
@app.get("/learning_chat")
async def learning_chat_root():
return FileResponse(DIST_DIR / "index.html")
logger.info("群聊学习 | 管理页面已挂载: http://<host>:<port>/learning_chat/")
@@ -2,7 +2,7 @@
"""群聊学习 Web API 子应用(挂载到 /api/learning_chat)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /learning_chat(JWT 自鉴权)保留可用,前端由 hub 渲染。
前端由统一管理台 hexi/web 渲染。
"""
from __future__ import annotations
@@ -45,9 +45,6 @@ async def _first_bot():
def build_admin_app() -> FastAPI | None:
"""构建群聊学习管理 API 子应用(挂载到 /api/learning_chat)。"""
if not config_manager.config.enable_web:
return None
app = FastAPI(title="Learning Chat API")
auth = API
@@ -72,3 +72,54 @@ async def add_new_jm_code(new_code: int) -> bool:
JM_CODES.append(new_code)
_safe_write_json(JM_CODES, FILE_PATH)
return True
async def list_jm_codes() -> list[str]:
"""读取全部 JM 码(Web 管理 / 导出用)。"""
await load_codes()
return list(JM_CODES)
async def remove_jm_code(code: str) -> bool:
"""删除一个 JM 码;不存在返回 False。"""
await load_codes()
code = str(code).strip()
async with LOCK:
if code not in JM_CODES:
return False
JM_CODES.remove(code)
_safe_write_json(JM_CODES, FILE_PATH)
return True
def _ensure_loaded_sync() -> None:
"""同步确保数据已载入(Web 配置读取/写入用,不走异步锁)。"""
global JM_CODES, _LOADED
if _LOADED:
return
_migrate_legacy()
if not FILE_PATH.exists():
JM_CODES = []
_safe_write_json(JM_CODES, FILE_PATH)
else:
try:
with open(FILE_PATH, "r", encoding="utf-8") as f:
JM_CODES = json.load(f)
except (json.JSONDecodeError, OSError):
JM_CODES = []
_LOADED = True
def sync_get_jm_codes() -> list[str]:
"""同步读取全部 JM 码(供通用配置项 getter)。"""
_ensure_loaded_sync()
return list(JM_CODES)
def sync_set_jm_codes(codes) -> None:
"""同步写入全部 JM 码(供通用配置项 setter),并落盘到 jm_code.json。"""
global JM_CODES
_ensure_loaded_sync()
cleaned = [str(c).strip() for c in (codes or []) if str(c).strip()]
JM_CODES = cleaned
_safe_write_json(JM_CODES, FILE_PATH)
+13
View File
@@ -1,3 +1,16 @@
@import "tailwindcss";
@import "@heroui/styles";
body { margin: 0; }
/* 页面切换动画:侧栏导航切换时内容淡入并轻微上移 */
@keyframes hexi-page-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.hexi-page-enter {
animation: hexi-page-enter 0.28s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.hexi-page-enter { animation: none; }
}