项目改造
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,
|
||||
|
||||
Reference in New Issue
Block a user