feat: revamp web hub — global connection status, array config editor, log streaming

- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 15:39:10 +08:00
co-authored by Claude Code
parent 9371a28e35
commit c53b210413
45 changed files with 1312 additions and 576 deletions
View File
@@ -0,0 +1,42 @@
# 插件模板(MTSS 结构)
按《插件结构标准 + 插件配置文件标准》编写的可复制插件骨架,含配置标准 + Web 子应用样例。
## 结构(MTSS)
```text
nonebot_plugin_template/
├── __init__.py # 入口(薄): require + meta + 导入 + config.register_config() + register_web_plugin
├── config.py # 配置注册(register_model_config / register_config_items / register_object_set)
├── handlers/ # Trigger 层
│ ├── __init__.py
│ └── entry.py # on_command 示例
├── services/ # Service 层(不 import nonebot)
│ └── main.py
├── models.py # 模型/异常
├── repository.py # Model 数据访问(唯一读写入口)
├── web/ # 可选: 插件自带 Web 子应用(FastAPI)
│ ├── __init__.py
│ └── admin.py # build_admin_app() → 挂载到 /api/template
├── data/ # 运行时数据
└── utils/ # 纯工具
```
## 使用
1. 把 `nonebot_plugin_template` 目录复制到 `hexi/plugins/nonebot_plugin_<name>`。
2. 全局搜索替换 `nonebot_plugin_template` → `nonebot_plugin_<name>`,包括 `_PLUGIN_ID` 相关说明。
3. 在 `__init__.py` 改 `__plugin_meta__` 的 name/description/usage,并把 `register_web_plugin("template", ...)` 的 id/name/icon 改掉;Web id 只能使用安全的短标识,不要直接使用带点的模块名。
4. 在 `config.py` 改成你要的配置项;在 `handlers/entry.py` 改成你的命令/触发。
5. 重启 bot;/hub 插件页能看到配置,点「打开 Web 页面」进入 `/hub/template`(子应用挂在 `/api/template`)。
## Web 子应用
- `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。
- 鉴权统一 `hexi.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `nonebot_plugin_web_hub`)。
## 约定
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。
- Trigger/View 可 import nonebot;Service/Model/repository 尽量不 import nonebot。
- 配置默认值写进 schema,Web 自动回填;敏感字段 `secret=True`。GET 只显示 `****`;保存配置时带上 GET 返回的 `revision`,修改冲突会返回 409。
- 同一 `plugin_id` 多次注册会自动合并(getter/apply 组合),但字段 key 应由单一注册者负责,避免定义冲突。
- Service 中的文件/数据库访问应使用异步 repository 或 `asyncio.to_thread`,不要阻塞事件循环。
@@ -0,0 +1,38 @@
"""<插件名> — MTSS 结构 + 统一配置标准参考模板。
放在 hexi/plugins/ 后改名:目录 nonebot_plugin_<name>,并替换所有
`nonebot_plugin_template` / `template` 占位(建议全局搜索替换)。
结构:Trigger(handlers) → Service(services) → Model(repository/models)。
配置在 config.py 统一注册,Web 可读可改。
"""
from nonebot import require
from nonebot.plugin import PluginMetadata
require("nonebot_plugin_alconna")
__plugin_meta__ = PluginMetadata(
name="插件模板",
description="MTSS 结构 + 统一配置标准参考模板",
usage="模板指令 [参数]",
type="application",
)
# 入口只做四件事:require / meta / 导入子模块 / 配置注册
from . import config as _config # noqa: E402,F401
from . import handlers as _handlers # noqa: E402,F401
from . import web as _web # noqa: E402,F401
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
_config.register_config()
# 注册到统一 Web 管理台(/hub):hub 启动时自动挂载 /api/template
register_web_plugin(
"template",
"插件模板",
"user",
lambda: _web.build_admin_app(),
module_name=__name__,
)
@@ -0,0 +1,92 @@
"""配置注册:按《插件配置文件标准》统一接入 Web 管理台。
此处演示三类:
1) register_model_config → 读 .env / NoneBot config 的 pydantic Config
2) register_config_items → 来源无关(模块常量)
3) register_object_set → 一组结构化条目(数组/对象集合)
"""
from __future__ import annotations
from nonebot import get_plugin_config
from pydantic import BaseModel
from hexi.config_standard import register_config_items, register_model_config, register_object_set
# plugin_id = NoneBot 插件模块名
_PLUGIN_ID = __package__
class Config(BaseModel):
enable: bool = True
reply: str = "你好,我是模板插件!"
config = get_plugin_config(Config)
# —— 来源无关示例:模块级常量(Web 可读可改) ——
_COLOR = "red"
def _get_color() -> str:
return _COLOR
def _set_color(v: str) -> None:
global _COLOR
_COLOR = v
# —— 对象集示例:一组条目(如服务器/关键词/白名单) ——
_ITEMS: dict[str, dict] = {}
def _obj_getter() -> list[dict]:
return [{"name": k, "value": v.get("value", "")} for k, v in _ITEMS.items()]
def _obj_setter(rows: list[dict]) -> None:
_ITEMS.clear()
for row in rows:
_ITEMS[str(row["name"])] = {"value": row.get("value", "")}
def register_config() -> None:
# 1) env/pydantic 配置
register_model_config(
_PLUGIN_ID,
config,
fields=["enable", "reply"],
labels={"enable": "启用", "reply": "回复内容"},
descriptions={"enable": "关闭后指令直接提示已停用", "reply": "模板指令的默认回复"},
)
# 2) 来源无关(模块常量)
register_config_items(
_PLUGIN_ID,
[
{
"key": "color",
"label": "主题色",
"type": "string",
"getter": _get_color,
"setter": _set_color,
"secret": False,
}
],
)
# 3) 对象集(数组/结构化条目)
register_object_set(
_PLUGIN_ID,
"dict_items",
[
{"key": "name", "label": "名称", "type": "string"},
{"key": "value", "label": "值", "type": "string"},
],
key_field="name",
getter=_obj_getter,
setter=_obj_setter,
label="条目表",
# nosave=True 表示权威源在插件自身(文件/DB);此处写进 plugin_config.json,故为 False
)
@@ -0,0 +1,3 @@
"""handlers 包:触发器(Trigger)层。"""
from .entry import cmd # noqa: F401
@@ -0,0 +1,29 @@
"""触发器(Trigger)层:接触发 → 鉴权 → 调 service → 出 View。"""
from __future__ import annotations
from nonebot import on_command
from nonebot.adapters import Message
from nonebot.rule import to_me
from nonebot.params import CommandArg
from nonebot_plugin_alconna import UniMessage
from hexi.web_config import get_effective_value
from ..services.main import do_something
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
cmd = on_command("模板指令", aliases={"模板"}, rule=to_me(), priority=10, block=True)
@cmd.handle()
async def handle(arg: Message = CommandArg()):
if not get_effective_value(_PLUGIN_ID, "enable", True):
await cmd.finish("模板插件已停用")
return
text = arg.extract_plain_text().strip()
if not text:
await cmd.finish("请提供要记录的内容")
result = await do_something(text)
await UniMessage.text(result).send()
@@ -0,0 +1,6 @@
"""模型/异常定义(可选)。"""
class TemplateError(Exception):
"""模板插件业务异常。"""
pass
@@ -0,0 +1,37 @@
"""Model 层的数据访问:唯一读写入口(此处用 JSON 文件示例)。"""
from __future__ import annotations
import json
import os
from pathlib import Path
# Runtime data belongs under hexi/data, not beside importable plugin code.
_DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "nonebot_plugin_template"
_STORE = _DATA_DIR / "store.json"
def _load() -> dict:
if _STORE.exists():
try:
return json.loads(_STORE.read_text("utf-8"))
except json.JSONDecodeError:
return {}
return {}
def _save(data: dict) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = _STORE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
os.replace(tmp, _STORE) # 原子写
def get_record(key: str):
return _load().get(key)
def save_record(key: str, value) -> None:
data = _load()
data[key] = value
_save(data)
@@ -0,0 +1,3 @@
"""services 包:业务编排层(不应 import nonebot)。"""
from .main import do_something # noqa: F401
@@ -0,0 +1,20 @@
"""业务逻辑层:一个业务动作一个方法;不依赖 nonebot(可单测)。"""
from __future__ import annotations
from hexi.web_config import get_effective_value
from ..repository import get_record, save_record
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
async def do_something(text: str) -> str:
"""示例业务:记录输入并返回带配置的回复。"""
save_record("last_input", text)
prefix = get_effective_value(_PLUGIN_ID, "reply", "你好,我是模板插件!")
record = get_record("last_input")
if record:
return f"{prefix} 已记录:{record}"
return prefix
@@ -0,0 +1,4 @@
"""纯工具:无副作用、尽量不 import nonebot,可单测。"""
def slugify(text: str) -> str:
return text.lower().replace(" ", "-")
@@ -0,0 +1,3 @@
"""web 子应用:插件自带管理后台(FastAPI),由 hub 自动挂载到 /api/<id>。"""
from .admin import build_admin_app # noqa: F401
@@ -0,0 +1,40 @@
"""插件 Web 子应用样例。
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
hub 启动时自动挂载到 `/api/template`。
- 鉴权统一走 `hexi.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
`Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
- 可直接读写 `hexi.web_config` 的插件配置。
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from hexi import web_config
from hexi.web_auth import require_admin as API
# 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
def build_admin_app() -> FastAPI:
app = FastAPI(title="插件模板管理")
@app.get("/")
async def index(_: dict = API) -> HTMLResponse:
return HTMLResponse("<h1>插件模板 · Web 子应用</h1><p>鉴权已生效。</p>")
@app.get("/ping")
async def ping(_: dict = API):
return {"ok": True, "plugin": _PLUGIN_ID}
@app.get("/config")
async def get_config(_: dict = API):
cfg = web_config.get_config(_PLUGIN_ID)
if cfg is None:
return {"ok": False, "msg": "未注册配置 schema"}
return {"ok": True, "plugin": _PLUGIN_ID, **cfg}
return app