项目改造
This commit is contained in:
@@ -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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[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>
|
||||
-7159
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>
|
||||
-1559
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();
|
||||
-54
@@ -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');
|
||||
-105
@@ -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));
|
||||
}
|
||||
-264
@@ -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>
|
||||
-170
@@ -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>
|
||||
-586
@@ -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>
|
||||
-280
@@ -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>
|
||||
-159
@@ -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>
|
||||
-188
@@ -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>
|
||||
-5
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user