From ed6e27f162d75a50977058554a511816508ffb54 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sun, 3 May 2026 00:01:10 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9B=91=E5=90=AC=E8=8C=83=E5=9B=B4=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=92=8C=E5=9B=9E=E5=A4=8D=20@=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 COMMAND_SCOPE 配置,支持 all/group/private 过滤消息来源 - 新增 COMMAND_ALLOWED_GROUPS 群号白名单,逗号分隔,留空不限制 - 新增 COMMAND_ALLOWED_USERS QQ 号白名单,逗号分隔,留空不限制 - 新增 COMMAND_AT_SENDER 配置,控制回复时是否 @发送者(默认 true) - 回调响应中 at_sender 字段可覆盖全局配置 - 更新 .env.example 和 README.md 文档 --- .env.example | 16 ++++++++++++-- README.md | 33 ++++++++++++++++++++++------- config.py | 8 +++++++ handlers/command.py | 7 ++++--- plugin.py | 51 ++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 1b356e5..89736f9 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,19 @@ QQ_API_MAX_RETRIES=2 # ── 命令监听 ── # 命令前缀,默认 # COMMAND_PREFIX=# -# 命令名长度(中文字数),默认 4 -COMMAND_LENGTH=4 +# 命令名最小字符数,默认 2 +COMMAND_LENGTH_MIN=2 +# 命令名最大字符数,默认 4 +COMMAND_LENGTH_MAX=4 +# 监听范围:all(群+私)、group(仅群)、private(仅私),默认 all +COMMAND_SCOPE=all +# 允许的群号,逗号分隔,留空不限制,例:123456,789012 +COMMAND_ALLOWED_GROUPS= +# 允许的 QQ 号,逗号分隔,留空不限制,例:111111,222222 +COMMAND_ALLOWED_USERS= +# 回复时是否 @发送者,默认 true +COMMAND_AT_SENDER=true +# 回调超时秒数,默认 180(生图等耗时命令需要较长超时) +COMMAND_CALLBACK_TIMEOUT=180 # 匹配到命令后的回调 URL,留空则不监听 COMMAND_CALLBACK_URL= diff --git a/README.md b/README.md index 999e728..8906bde 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,13 @@ uv run python -m ncatbot | `QQ_API_TIMEOUT` | 否 | `10` | QQ API 超时秒数 | | `QQ_API_MAX_RETRIES` | 否 | `2` | QQ API 失败重试次数 | | `COMMAND_PREFIX` | 否 | `#` | 命令前缀 | -| `COMMAND_LENGTH` | 否 | `4` | 命令名字符数(中文字数) | +| `COMMAND_LENGTH_MIN` | 否 | `2` | 命令名最小字符数 | +| `COMMAND_LENGTH_MAX` | 否 | `4` | 命令名最大字符数 | +| `COMMAND_SCOPE` | 否 | `all` | 监听范围:`all`(群+私)、`group`(仅群)、`private`(仅私) | +| `COMMAND_ALLOWED_GROUPS` | 否 | 空(不限) | 允许的群号,逗号分隔,如 `123456,789012` | +| `COMMAND_ALLOWED_USERS` | 否 | 空(不限) | 允许的 QQ 号,逗号分隔,如 `111111,222222` | +| `COMMAND_AT_SENDER` | 否 | `true` | 回复时是否 @发送者 | +| `COMMAND_CALLBACK_TIMEOUT` | 否 | `180` | 回调超时秒数 | | `COMMAND_CALLBACK_URL` | 否 | 空(不监听) | 命令匹配后的回调 URL | ## 接口说明 @@ -187,22 +193,34 @@ curl -X POST http://localhost:8081/webhook \ ### 命令监听 -插件会自动监听 QQ 消息,当消息以 `#四个中文字+空格` 开头时,将命令内容 POST 到 `COMMAND_CALLBACK_URL`。 +插件会自动监听 QQ 消息,当消息以 `#命令名` 开头时,将命令内容 POST 到 `COMMAND_CALLBACK_URL`。 **匹配规则:** ``` #测试命令 你好世界 │ │ │ │ -│ │ │ └── 命令内容(content) -│ └── 空格分隔 -└── 命令名(4个中文字) +│ │ │ └── 命令内容(content,可选) +│ └── 空格分隔(可选) +└── 命令名(2~4个字符) └── 前缀(默认 #) ``` -- 前缀、命令名长度可通过 `COMMAND_PREFIX`、`COMMAND_LENGTH` 配置 +- 命令名支持中文、数字、字母等任意非空白字符,每个字符计 1 +- 前缀通过 `COMMAND_PREFIX` 配置,长度范围通过 `COMMAND_LENGTH_MIN` / `COMMAND_LENGTH_MAX` 配置 +- `#测试命令`、`#1a`、`#abc` 均可触发 - 不配置 `COMMAND_CALLBACK_URL` 则不监听 +**监听范围过滤:** + +- `COMMAND_SCOPE`:控制监听范围 + - `all`(默认):群聊 + 私聊都监听 + - `group`:仅监听群聊 + - `private`:仅监听私聊 +- `COMMAND_ALLOWED_GROUPS`:群号白名单,逗号分隔,留空不限制 +- `COMMAND_ALLOWED_USERS`:QQ 号白名单,逗号分隔,留空不限制 +- 三个条件同时生效,必须全部满足才触发回调 + **回调请求体(POST JSON):** ```json @@ -218,7 +236,7 @@ curl -X POST http://localhost:8081/webhook \ | 字段 | 说明 | |---|---| -| `command` | 命令名(4个中文字) | +| `command` | 命令名(2~4个字符) | | `content` | 命令后的内容 | | `raw_message` | 原始消息文本 | | `user_id` | 发送者 QQ 号 | @@ -249,6 +267,7 @@ curl -X POST http://localhost:8081/webhook \ |---|---| | `reply` | 纯文本回复(与 `messages` 二选一,`messages` 优先) | | `messages` | 批量回复数组,格式同 `/webhook` 的 `messages` 字段 | +| `at_sender` | 是否 @发送者(默认取 `COMMAND_AT_SENDER` 配置,仅群聊生效) | | `group_id` | 可选,覆盖回复目标群号(默认回复到原群) | | `user_id` | 可选,覆盖回复目标 QQ 号(默认回复到原发送者) | diff --git a/config.py b/config.py index 123c596..c20c3b1 100644 --- a/config.py +++ b/config.py @@ -38,3 +38,11 @@ COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2")) COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4")) COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")) +COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private +COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) +) +COMMAND_ALLOWED_USERS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(",")) +) +COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes") diff --git a/handlers/command.py b/handlers/command.py index ada6358..6f256f0 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -4,7 +4,7 @@ import re import aiohttp -from ..config import COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR +from ..config import COMMAND_AT_SENDER, COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR from ..handlers.message import _resolve_url @@ -50,7 +50,7 @@ async def send_command_callback(data: dict, event, api, logger) -> None: {"type": "file", "url": "..."}, {"type": "video", "url": "..."} ], - "at_sender": true // 是否 @发送者(默认 true,仅群聊) + "at_sender": true // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊) } 所有字段均为可选,无回复内容时返回空 JSON 即可。 @@ -92,7 +92,8 @@ async def send_command_callback(data: dict, event, api, logger) -> None: async def _handle_reply(result: dict, msg_event, api, logger) -> None: """处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。""" - at_sender = result.get("at_sender", True) + # at_sender: 回调响应中的值优先,未指定则使用全局配置 + at_sender = result.get("at_sender", COMMAND_AT_SENDER) messages = result.get("messages") reply = result.get("reply") group_id = getattr(msg_event, "group_id", None) diff --git a/plugin.py b/plugin.py index 85cf13b..88a2c41 100644 --- a/plugin.py +++ b/plugin.py @@ -7,7 +7,20 @@ import os from aiohttp import web from ncatbot.plugin import NcatBotPlugin -from .config import COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY +from .config import ( + COMMAND_ALLOWED_GROUPS, + COMMAND_ALLOWED_USERS, + COMMAND_AT_SENDER, + COMMAND_CALLBACK_URL, + COMMAND_LENGTH_MAX, + COMMAND_LENGTH_MIN, + COMMAND_PREFIX, + COMMAND_SCOPE, + HOST, + PORT, + UPLOAD_DIR, + WEBHOOK_API_KEY, +) from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler from .handlers.message import webhook_handler @@ -31,9 +44,18 @@ class WebHookPlugin(NcatBotPlugin): async def on_load(self): self.logger.info("Webhook 插件已加载") - self.logger.info("WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成") - self.logger.info("命令监听: 前缀=%s 长度=%d~%d 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX, - COMMAND_CALLBACK_URL or "未配置") + self.logger.info( + "WEBHOOK_API_KEY: %s", + "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成", + ) + self.logger.info( + "命令监听: 前缀=%s 长度=%d~%d 范围=%s 回调=%s", + COMMAND_PREFIX, + COMMAND_LENGTH_MIN, + COMMAND_LENGTH_MAX, + COMMAND_SCOPE, + COMMAND_CALLBACK_URL or "未配置", + ) asyncio.create_task(self._start_webhook()) self._cleanup_task = asyncio.create_task(self._cleanup_loop()) self._listener_task = asyncio.create_task(self._message_listener()) @@ -77,6 +99,23 @@ class WebHookPlugin(NcatBotPlugin): parsed = parse_command(raw_message) if not parsed: continue + + # 范围过滤:group / private / all + is_group = hasattr(event.data, "group_id") + if COMMAND_SCOPE == "group" and not is_group: + continue + if COMMAND_SCOPE == "private" and is_group: + continue + + # 群白名单过滤 + if COMMAND_ALLOWED_GROUPS and is_group: + if event.data.group_id not in COMMAND_ALLOWED_GROUPS: + continue + + # 用户白名单过滤 + if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: + continue + # 构建回调数据 data = { "command": parsed["command"], @@ -89,7 +128,9 @@ class WebHookPlugin(NcatBotPlugin): data["group_id"] = event.data.group_id self.logger.info( "命令监听匹配: command=%s user=%s group=%s", - parsed["command"], data["user_id"], data.get("group_id", "-"), + parsed["command"], + data["user_id"], + data.get("group_id", "-"), ) asyncio.create_task( send_command_callback(data, event, self.api, self.logger) -- 2.49.1 From 9ffe78a9c27f9751b8783beff8e246d5c4dd9bbd Mon Sep 17 00:00:00 2001 From: zhilv Date: Sun, 3 May 2026 15:22:53 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8A=A8=E6=80=81=E9=85=8D=E7=BD=AE=E3=80=81=E9=BB=91?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E4=B8=8E=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SQLite 数据库层(db.py)持久化命令监听配置,支持热更新无需重启 - 命令过滤从白名单扩展为黑白名单双模式(COMMAND_LIST_MODE: allow/deny) - 新增后台管理页面 /admin/,侧边栏布局,支持在线修改所有命令监听配置 - 新增 REST API:GET/PUT /api/settings、POST /api/settings/reload - 新增 rebuild_pattern() 支持配置变更后正则动态重编译 - 中间件放行 /admin 和 /api 路径免鉴权 - 添加 aiosqlite 依赖 --- config.py | 63 ++- db.py | 82 ++++ handlers/admin.py | 996 ++++++++++++++++++++++++++++++++++++++++++++ handlers/command.py | 6 + middleware.py | 6 +- plugin.py | 43 +- pyproject.toml | 1 + uv.lock | 11 + 8 files changed, 1194 insertions(+), 14 deletions(-) create mode 100644 db.py create mode 100644 handlers/admin.py diff --git a/config.py b/config.py index c20c3b1..5faad36 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,4 @@ -"""项目配置:所有值从环境变量读取,未配置时使用安全默认值。""" +"""项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改。""" import os import uuid @@ -32,17 +32,76 @@ ALLOWED_EXTENSIONS: set[str] = set( QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10")) QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2")) -# ── 命令监听 ──────────────────────────────────────────────── +# ── 命令监听(可动态修改,从数据库加载) ────────────────────── COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2")) COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4")) COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")) COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private +COMMAND_LIST_MODE: str = os.environ.get("COMMAND_LIST_MODE", "allow") # allow / deny COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset( filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) ) +COMMAND_DENIED_GROUPS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_DENIED_GROUPS", "").split(",")) +) COMMAND_ALLOWED_USERS: frozenset[str] = frozenset( filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(",")) ) +COMMAND_DENIED_USERS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_DENIED_USERS", "").split(",")) +) COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes") + + +# ── 动态配置刷新 ───────────────────────────────────────────── +def _parse_frozenset(value: str) -> frozenset[str]: + """将逗号分隔字符串解析为 frozenset。""" + return frozenset(filter(None, (v.strip() for v in value.split(",")))) + + +def reload_settings(settings: dict[str, str]) -> None: + """从数据库读取的配置覆盖模块级变量,使配置动态生效。""" + global COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX + global COMMAND_CALLBACK_URL, COMMAND_CALLBACK_TIMEOUT, COMMAND_SCOPE + global COMMAND_LIST_MODE, COMMAND_ALLOWED_GROUPS, COMMAND_DENIED_GROUPS + global COMMAND_ALLOWED_USERS, COMMAND_DENIED_USERS, COMMAND_AT_SENDER + + if "command_prefix" in settings: + COMMAND_PREFIX = settings["command_prefix"] + if "command_length_min" in settings: + try: + COMMAND_LENGTH_MIN = int(settings["command_length_min"]) + except ValueError: + pass + if "command_length_max" in settings: + try: + COMMAND_LENGTH_MAX = int(settings["command_length_max"]) + except ValueError: + pass + if "command_callback_url" in settings: + COMMAND_CALLBACK_URL = settings["command_callback_url"] + if "command_callback_timeout" in settings: + try: + COMMAND_CALLBACK_TIMEOUT = int(settings["command_callback_timeout"]) + except ValueError: + pass + if "command_scope" in settings: + COMMAND_SCOPE = settings["command_scope"] + if "command_list_mode" in settings: + COMMAND_LIST_MODE = settings["command_list_mode"] + if "command_allowed_groups" in settings: + COMMAND_ALLOWED_GROUPS = _parse_frozenset(settings["command_allowed_groups"]) + if "command_denied_groups" in settings: + COMMAND_DENIED_GROUPS = _parse_frozenset(settings["command_denied_groups"]) + if "command_allowed_users" in settings: + COMMAND_ALLOWED_USERS = _parse_frozenset(settings["command_allowed_users"]) + if "command_denied_users" in settings: + COMMAND_DENIED_USERS = _parse_frozenset(settings["command_denied_users"]) + if "command_at_sender" in settings: + COMMAND_AT_SENDER = settings["command_at_sender"].lower() in ("true", "1", "yes") + + # 刷新正则编译缓存 + from .handlers.command import rebuild_pattern + rebuild_pattern() diff --git a/db.py b/db.py new file mode 100644 index 0000000..0084843 --- /dev/null +++ b/db.py @@ -0,0 +1,82 @@ +"""SQLite 数据库层:存储可动态修改的配置项。""" + +import aiosqlite +from pathlib import Path + +DB_PATH = Path(__file__).parent / "data" / "webhook.db" + +# 需要持久化的配置项及其默认值(从 .env 加载时的回退) +SETTINGS_DEFAULTS: dict[str, str] = { + "command_prefix": "#", + "command_length_min": "2", + "command_length_max": "4", + "command_scope": "all", + "command_list_mode": "allow", + "command_allowed_groups": "", + "command_denied_groups": "", + "command_allowed_users": "", + "command_denied_users": "", + "command_at_sender": "true", + "command_callback_url": "", + "command_callback_timeout": "180", +} + + +async def init_db() -> None: + """建表 + 首次启动时写入默认值。""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ) + """ + ) + await db.commit() + + # 首次启动:插入不存在的默认值 + for key, default in SETTINGS_DEFAULTS.items(): + await db.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, default), + ) + await db.commit() + + +async def get_setting(key: str) -> str | None: + """读取单个配置值,不存在返回 None。""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = await cursor.fetchone() + return row[0] if row else None + + +async def get_settings() -> dict[str, str]: + """读取全部配置,返回 {key: value} 字典。""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("SELECT key, value FROM settings") + rows = await cursor.fetchall() + return {row[0]: row[1] for row in rows} + + +async def update_setting(key: str, value: str) -> None: + """更新单个配置值。""" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value), + ) + await db.commit() + + +async def update_settings(data: dict[str, str]) -> None: + """批量更新配置值。""" + async with aiosqlite.connect(DB_PATH) as db: + for key, value in data.items(): + await db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value), + ) + await db.commit() diff --git a/handlers/admin.py b/handlers/admin.py new file mode 100644 index 0000000..421789a --- /dev/null +++ b/handlers/admin.py @@ -0,0 +1,996 @@ +"""后台管理:提供 Web 管理界面和 REST API,动态修改命令监听配置。""" + +from aiohttp import web + +from ..config import ( + COMMAND_AT_SENDER, + COMMAND_CALLBACK_TIMEOUT, + COMMAND_CALLBACK_URL, + COMMAND_DENIED_GROUPS, + COMMAND_DENIED_USERS, + COMMAND_ALLOWED_GROUPS, + COMMAND_ALLOWED_USERS, + COMMAND_LENGTH_MAX, + COMMAND_LENGTH_MIN, + COMMAND_LIST_MODE, + COMMAND_PREFIX, + COMMAND_SCOPE, + reload_settings, +) +from ..db import get_settings, update_settings +from ..response import error, ok + + +# ── API ────────────────────────────────────────────────────── + +async def api_get_settings(request: web.Request) -> web.Response: + """GET /api/settings — 返回全部动态配置。""" + settings = await get_settings() + return ok(data=settings) + + +async def api_update_settings(request: web.Request) -> web.Response: + """PUT /api/settings — 批量更新配置并立即生效。""" + try: + data = await request.json() + except Exception: + return error("invalid json") + + if not isinstance(data, dict): + return error("request body must be a json object") + + # 允许更新的 key 白名单 + allowed_keys = { + "command_prefix", + "command_length_min", + "command_length_max", + "command_scope", + "command_list_mode", + "command_allowed_groups", + "command_denied_groups", + "command_allowed_users", + "command_denied_users", + "command_at_sender", + "command_callback_url", + "command_callback_timeout", + } + + filtered = {k: str(v) for k, v in data.items() if k in allowed_keys} + if not filtered: + return error("no valid settings to update") + + await update_settings(filtered) + reload_settings(filtered) + + return ok(data=filtered, msg="配置已更新并生效") + + +async def api_reload_settings(request: web.Request) -> web.Response: + """POST /api/settings/reload — 从数据库重新加载配置。""" + settings = await get_settings() + reload_settings(settings) + return ok(msg="配置已从数据库重新加载") + + +# ── 管理页面 ───────────────────────────────────────────────── + +ADMIN_HTML = r""" + + + + +NcatBot Webhook 管理 + + + + + + + + +
+ +
+
命令监听配置
+
+ ● 运行中 + + +
+
+ + +
+ + +
+
+

基本设置

+
+
+ 修改配置后点击右上角「保存配置」即可生效,无需重启服务。 +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + 回复时 @发送者(仅群聊生效) +
+
+
+
+ +
+

命令匹配预览

+
+
+ + +
+
输入消息后自动检测
+
+
+
+ + + + + + + + + + +
+
+ + +
+ + + +""" + + +async def admin_page_handler(request: web.Request) -> web.Response: + """GET /admin/ — 返回管理页面 HTML。""" + return web.Response(text=ADMIN_HTML, content_type="text/html") diff --git a/handlers/command.py b/handlers/command.py index 6f256f0..d73a4b9 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -26,6 +26,12 @@ def build_command_pattern() -> re.Pattern: COMMAND_PATTERN = build_command_pattern() +def rebuild_pattern() -> None: + """动态配置变更后重新编译正则。""" + global COMMAND_PATTERN + COMMAND_PATTERN = build_command_pattern() + + def parse_command(raw_message: str) -> dict | None: """解析消息,匹配命令模式。返回 {command, content, raw_message} 或 None。""" match = COMMAND_PATTERN.match(raw_message.strip()) diff --git a/middleware.py b/middleware.py index dba4e91..9ddd2ff 100644 --- a/middleware.py +++ b/middleware.py @@ -10,9 +10,9 @@ from .response import error @web.middleware async def auth_middleware(request: web.Request, handler): - """对 /upload 和 /webhook 路径强制校验 API Key。""" - # 健康检查不需要鉴权 - if request.path == "/healthz": + """对需要鉴权的路径校验 API Key。/healthz 和 /admin/ 及 /api/ 开头的路径不需要鉴权。""" + # 不需要鉴权的路径 + if request.path == "/healthz" or request.path.startswith("/admin") or request.path.startswith("/api/"): return await handler(request) auth_header = request.headers.get("Authorization", "") diff --git a/plugin.py b/plugin.py index 88a2c41..c71e07f 100644 --- a/plugin.py +++ b/plugin.py @@ -12,15 +12,21 @@ from .config import ( COMMAND_ALLOWED_USERS, COMMAND_AT_SENDER, COMMAND_CALLBACK_URL, + COMMAND_DENIED_GROUPS, + COMMAND_DENIED_USERS, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, + COMMAND_LIST_MODE, COMMAND_PREFIX, COMMAND_SCOPE, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY, + reload_settings, ) +from .db import get_settings, init_db +from .handlers.admin import admin_page_handler, api_get_settings, api_reload_settings, api_update_settings from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler from .handlers.message import webhook_handler @@ -43,17 +49,23 @@ class WebHookPlugin(NcatBotPlugin): self._listener_task: asyncio.Task | None = None async def on_load(self): + # 初始化数据库并加载动态配置 + await init_db() + settings = await get_settings() + reload_settings(settings) + self.logger.info("Webhook 插件已加载") self.logger.info( "WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成", ) self.logger.info( - "命令监听: 前缀=%s 长度=%d~%d 范围=%s 回调=%s", + "命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX, COMMAND_SCOPE, + COMMAND_LIST_MODE, COMMAND_CALLBACK_URL or "未配置", ) asyncio.create_task(self._start_webhook()) @@ -107,14 +119,21 @@ class WebHookPlugin(NcatBotPlugin): if COMMAND_SCOPE == "private" and is_group: continue - # 群白名单过滤 - if COMMAND_ALLOWED_GROUPS and is_group: - if event.data.group_id not in COMMAND_ALLOWED_GROUPS: + # 黑白名单过滤 + if COMMAND_LIST_MODE == "allow": + # 白名单模式:在名单内才放行 + if COMMAND_ALLOWED_GROUPS and is_group: + if event.data.group_id not in COMMAND_ALLOWED_GROUPS: + continue + if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: + continue + elif COMMAND_LIST_MODE == "deny": + # 黑名单模式:在名单内则拒绝 + if COMMAND_DENIED_GROUPS and is_group: + if event.data.group_id in COMMAND_DENIED_GROUPS: + continue + if COMMAND_DENIED_USERS and event.data.user_id in COMMAND_DENIED_USERS: continue - - # 用户白名单过滤 - if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: - continue # 构建回调数据 data = { @@ -124,7 +143,7 @@ class WebHookPlugin(NcatBotPlugin): "user_id": event.data.user_id, "message_id": event.data.message_id, } - if hasattr(event.data, "group_id"): + if is_group: data["group_id"] = event.data.group_id self.logger.info( "命令监听匹配: command=%s user=%s group=%s", @@ -147,6 +166,11 @@ class WebHookPlugin(NcatBotPlugin): app.router.add_get("/healthz", health_handler) app.router.add_post("/webhook", webhook_handler) app.router.add_post("/upload", upload_handler) + # 后台管理 + app.router.add_get("/admin/", admin_page_handler) + app.router.add_get("/api/settings", api_get_settings) + app.router.add_put("/api/settings", api_update_settings) + app.router.add_post("/api/settings/reload", api_reload_settings) return app async def _start_webhook(self): @@ -158,6 +182,7 @@ class WebHookPlugin(NcatBotPlugin): await site.start() self.logger.info("Webhook 已启动: %s:%d", HOST, PORT) self.logger.info("上传目录: %s", UPLOAD_DIR) + self.logger.info("后台管理: http://%s:%d/admin/", HOST, PORT) async def _stop_webhook(self): if self._webhook_runner is not None: diff --git a/pyproject.toml b/pyproject.toml index 4e87235..eeb1c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,5 @@ dependencies = [ "ncatbot5>=5.5.2.post3", "aiohttp>=3.9", "python-dotenv>=1.0", + "aiosqlite>=0.20", ] diff --git a/uv.lock b/uv.lock index 695243e..137f749 100644 --- a/uv.lock +++ b/uv.lock @@ -109,6 +109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -909,6 +918,7 @@ version = "0.1.1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "aiosqlite" }, { name = "ncatbot5" }, { name = "python-dotenv" }, ] @@ -916,6 +926,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.9" }, + { name = "aiosqlite", specifier = ">=0.20" }, { name = "ncatbot5", specifier = ">=5.5.2.post3" }, { name = "python-dotenv", specifier = ">=1.0" }, ] -- 2.49.1 From f82363f45f338ac8077509c4f0c9098aaeea32af Mon Sep 17 00:00:00 2001 From: zhilv Date: Sun, 3 May 2026 18:23:29 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(command):=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F=E4=BB=8E=20SQLite=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=87=B3=20YAML=20=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=99=BD=E5=90=8D=E5=8D=95=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 CommandConfig dataclass 单例替代模块级变量,解决 from import 造成的本地绑定不随 global 更新的 bug - 删除 db.py,改用 settings.yaml 存储动态配置,首次启动自动创建并合并 .env 默认值 - 新增文件轮询 watcher(2 秒),检测 YAML 变更自动热重载 - 管理界面 API 改为直接读写 YAML,即时生效 - 依赖 aiosqlite 替换为 pyyaml --- .gitignore | 6 + config.py | 307 +++++++++++++++++++++++++++++++++----------- db.py | 82 ------------ handlers/admin.py | 58 ++------- handlers/command.py | 20 +-- plugin.py | 96 +++++++------- pyproject.toml | 2 +- uv.lock | 13 +- 8 files changed, 314 insertions(+), 270 deletions(-) delete mode 100644 db.py diff --git a/.gitignore b/.gitignore index 9a2006c..89d7581 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ uploads/ # Environment .env + +# Dynamic config +settings.yaml + +# Legacy data +data/ diff --git a/config.py b/config.py index 5faad36..6d846cd 100644 --- a/config.py +++ b/config.py @@ -1,107 +1,270 @@ -"""项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改。""" +"""项目配置:静态配置从环境变量读取,命令监听配置通过 settings.yaml 动态管理。""" +import logging import os import uuid +from dataclasses import dataclass from pathlib import Path +import yaml from dotenv import load_dotenv # 加载 .env 文件(优先从插件目录查找) load_dotenv(Path(__file__).parent / ".env") -# ── 鉴权 ──────────────────────────────────────────────────── -WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex +logger = logging.getLogger("webhook-plugin.config") -# ── 网络 ───────────────────────────────────────────────────── +# ── 静态配置(环境变量,运行时不变)──────────────────────────── +WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0") try: PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081")) except ValueError: PORT = 8081 -# ── 上传 ───────────────────────────────────────────────────── UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads"))) -# 单个文件最大 20 MB MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", str(20 * 1024 * 1024))) -# 允许的文件扩展名(小写,不含点),为空则不限制 ALLOWED_EXTENSIONS: set[str] = set( filter(None, os.environ.get("ALLOWED_EXTENSIONS", "").lower().split(",")) ) -# ── QQ API ─────────────────────────────────────────────────── QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10")) QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2")) -# ── 命令监听(可动态修改,从数据库加载) ────────────────────── -COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") -COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2")) -COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4")) -COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") -COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")) -COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private -COMMAND_LIST_MODE: str = os.environ.get("COMMAND_LIST_MODE", "allow") # allow / deny -COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset( - filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) +# ── YAML 文件路径 ──────────────────────────────────────────── +SETTINGS_YAML_PATH: Path = Path(__file__).parent / "settings.yaml" + +# ── 动态配置 dataclass ────────────────────────────────────── +@dataclass +class CommandConfig: + """命令监听动态配置,通过 settings.yaml 热重载。""" + + prefix: str = "#" + length_min: int = 2 + length_max: int = 4 + scope: str = "all" # all / group / private + list_mode: str = "allow" # allow / deny + allowed_groups: frozenset[str] = frozenset() + denied_groups: frozenset[str] = frozenset() + allowed_users: frozenset[str] = frozenset() + denied_users: frozenset[str] = frozenset() + at_sender: bool = True + callback_url: str = "" + callback_timeout: int = 180 + + +# 全局单例 —— 所有消费者通过 config.command.xxx 访问,避免 stale import 问题 +command = CommandConfig() + +# ── 环境变量默认值(YAML 缺失 key 时的回退)──────────────────── +_ENV_DEFAULTS: dict = { + "prefix": os.environ.get("COMMAND_PREFIX", "#"), + "length_min": int(os.environ.get("COMMAND_LENGTH_MIN", "2")), + "length_max": int(os.environ.get("COMMAND_LENGTH_MAX", "4")), + "scope": os.environ.get("COMMAND_SCOPE", "all"), + "list_mode": os.environ.get("COMMAND_LIST_MODE", "allow"), + "allowed_groups": os.environ.get("COMMAND_ALLOWED_GROUPS", ""), + "denied_groups": os.environ.get("COMMAND_DENIED_GROUPS", ""), + "allowed_users": os.environ.get("COMMAND_ALLOWED_USERS", ""), + "denied_users": os.environ.get("COMMAND_DENIED_USERS", ""), + "at_sender": os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes"), + "callback_url": os.environ.get("COMMAND_CALLBACK_URL", ""), + "callback_timeout": int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")), +} + +# ── YAML I/O ───────────────────────────────────────────────── +_YAML_HEADER = ( + "# settings.yaml - 命令监听动态配置\n" + "# 可通过后台管理界面修改,也可手动编辑(自动热重载)\n\n" ) -COMMAND_DENIED_GROUPS: frozenset[str] = frozenset( - filter(None, os.environ.get("COMMAND_DENIED_GROUPS", "").split(",")) -) -COMMAND_ALLOWED_USERS: frozenset[str] = frozenset( - filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(",")) -) -COMMAND_DENIED_USERS: frozenset[str] = frozenset( - filter(None, os.environ.get("COMMAND_DENIED_USERS", "").split(",")) -) -COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes") -# ── 动态配置刷新 ───────────────────────────────────────────── -def _parse_frozenset(value: str) -> frozenset[str]: - """将逗号分隔字符串解析为 frozenset。""" - return frozenset(filter(None, (v.strip() for v in value.split(",")))) +def _yaml_defaults() -> dict: + """返回默认 YAML 结构(首次启动时写入)。""" + return { + "command": { + "prefix": "#", + "length_min": 2, + "length_max": 4, + "scope": "all", + "list_mode": "allow", + "allowed_groups": [], + "denied_groups": [], + "allowed_users": [], + "denied_users": [], + "at_sender": True, + "callback_url": "", + "callback_timeout": 180, + } + } -def reload_settings(settings: dict[str, str]) -> None: - """从数据库读取的配置覆盖模块级变量,使配置动态生效。""" - global COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX - global COMMAND_CALLBACK_URL, COMMAND_CALLBACK_TIMEOUT, COMMAND_SCOPE - global COMMAND_LIST_MODE, COMMAND_ALLOWED_GROUPS, COMMAND_DENIED_GROUPS - global COMMAND_ALLOWED_USERS, COMMAND_DENIED_USERS, COMMAND_AT_SENDER +def load_yaml() -> dict: + """读取 settings.yaml,文件不存在时返回空字典。""" + if not SETTINGS_YAML_PATH.exists(): + return {} + with open(SETTINGS_YAML_PATH, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else {} - if "command_prefix" in settings: - COMMAND_PREFIX = settings["command_prefix"] - if "command_length_min" in settings: - try: - COMMAND_LENGTH_MIN = int(settings["command_length_min"]) - except ValueError: - pass - if "command_length_max" in settings: - try: - COMMAND_LENGTH_MAX = int(settings["command_length_max"]) - except ValueError: - pass - if "command_callback_url" in settings: - COMMAND_CALLBACK_URL = settings["command_callback_url"] - if "command_callback_timeout" in settings: - try: - COMMAND_CALLBACK_TIMEOUT = int(settings["command_callback_timeout"]) - except ValueError: - pass - if "command_scope" in settings: - COMMAND_SCOPE = settings["command_scope"] - if "command_list_mode" in settings: - COMMAND_LIST_MODE = settings["command_list_mode"] - if "command_allowed_groups" in settings: - COMMAND_ALLOWED_GROUPS = _parse_frozenset(settings["command_allowed_groups"]) - if "command_denied_groups" in settings: - COMMAND_DENIED_GROUPS = _parse_frozenset(settings["command_denied_groups"]) - if "command_allowed_users" in settings: - COMMAND_ALLOWED_USERS = _parse_frozenset(settings["command_allowed_users"]) - if "command_denied_users" in settings: - COMMAND_DENIED_USERS = _parse_frozenset(settings["command_denied_users"]) - if "command_at_sender" in settings: - COMMAND_AT_SENDER = settings["command_at_sender"].lower() in ("true", "1", "yes") - # 刷新正则编译缓存 +def save_yaml(data: dict) -> None: + """将配置写入 settings.yaml。""" + with open(SETTINGS_YAML_PATH, "w", encoding="utf-8") as f: + f.write(_YAML_HEADER) + yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + +def ensure_settings_yaml() -> None: + """首次启动时创建 settings.yaml,合并环境变量覆盖。""" + if SETTINGS_YAML_PATH.exists(): + return + data = _yaml_defaults() + # 用环境变量覆盖默认值 + env_map = { + "prefix": ("COMMAND_PREFIX", str), + "length_min": ("COMMAND_LENGTH_MIN", int), + "length_max": ("COMMAND_LENGTH_MAX", int), + "scope": ("COMMAND_SCOPE", str), + "list_mode": ("COMMAND_LIST_MODE", str), + "allowed_groups": ("COMMAND_ALLOWED_GROUPS", "csv_list"), + "denied_groups": ("COMMAND_DENIED_GROUPS", "csv_list"), + "allowed_users": ("COMMAND_ALLOWED_USERS", "csv_list"), + "denied_users": ("COMMAND_DENIED_USERS", "csv_list"), + "at_sender": ("COMMAND_AT_SENDER", "bool"), + "callback_url": ("COMMAND_CALLBACK_URL", str), + "callback_timeout": ("COMMAND_CALLBACK_TIMEOUT", int), + } + for key, (env_name, conv) in env_map.items(): + env_val = os.environ.get(env_name) + if env_val is not None: + if conv == "csv_list": + data["command"][key] = [v.strip() for v in env_val.split(",") if v.strip()] + elif conv == "bool": + data["command"][key] = env_val.lower() in ("true", "1", "yes") + else: + data["command"][key] = conv(env_val) + save_yaml(data) + logger.info("Created settings.yaml with defaults (env-var overrides applied)") + + +# ── 将 YAML 数据应用到全局 command 对象 ─────────────────────── +def _apply_yaml_to_command(data: dict) -> None: + """将 YAML command 段合并到全局 command 对象,缺失 key 回退到环境变量默认值。""" + cmd_data = data.get("command", {}) + + command.prefix = str(cmd_data.get("prefix", _ENV_DEFAULTS["prefix"])) + command.length_min = int(cmd_data.get("length_min", _ENV_DEFAULTS["length_min"])) + command.length_max = int(cmd_data.get("length_max", _ENV_DEFAULTS["length_max"])) + command.scope = str(cmd_data.get("scope", _ENV_DEFAULTS["scope"])) + command.list_mode = str(cmd_data.get("list_mode", _ENV_DEFAULTS["list_mode"])) + command.callback_url = str(cmd_data.get("callback_url", _ENV_DEFAULTS["callback_url"])) + command.callback_timeout = int(cmd_data.get("callback_timeout", _ENV_DEFAULTS["callback_timeout"])) + + # at_sender: YAML bool → Python bool + at_sender_val = cmd_data.get("at_sender", _ENV_DEFAULTS["at_sender"]) + if isinstance(at_sender_val, str): + command.at_sender = at_sender_val.lower() in ("true", "1", "yes") + else: + command.at_sender = bool(at_sender_val) + + # 列表字段: YAML list → frozenset + for field_name in ("allowed_groups", "denied_groups", "allowed_users", "denied_users"): + raw = cmd_data.get(field_name) + if raw is None: + # 回退到环境变量默认值 + env_default = _ENV_DEFAULTS[field_name] + if isinstance(env_default, str): + setattr(command, field_name, frozenset( + v.strip() for v in env_default.split(",") if v.strip() + )) + else: + setattr(command, field_name, frozenset(env_default) if env_default else frozenset()) + elif isinstance(raw, str): + setattr(command, field_name, frozenset( + v.strip() for v in raw.split(",") if v.strip() + )) + elif isinstance(raw, list): + setattr(command, field_name, frozenset(str(v).strip() for v in raw if str(v).strip())) + else: + setattr(command, field_name, frozenset()) + + # 重建命令匹配正则 from .handlers.command import rebuild_pattern rebuild_pattern() + + +# ── 公共 API ───────────────────────────────────────────────── +def reload_settings() -> None: + """重新读取 settings.yaml 并应用到全局 command 对象。""" + data = load_yaml() + _apply_yaml_to_command(data) + logger.info("Config reloaded from settings.yaml") + + +def get_settings_flat() -> dict[str, str]: + """返回所有动态配置的扁平字典 {key: str_value},供管理 API 使用。""" + return { + "command_prefix": command.prefix, + "command_length_min": str(command.length_min), + "command_length_max": str(command.length_max), + "command_scope": command.scope, + "command_list_mode": command.list_mode, + "command_allowed_groups": ",".join(sorted(command.allowed_groups)), + "command_denied_groups": ",".join(sorted(command.denied_groups)), + "command_allowed_users": ",".join(sorted(command.allowed_users)), + "command_denied_users": ",".join(sorted(command.denied_users)), + "command_at_sender": str(command.at_sender).lower(), + "command_callback_url": command.callback_url, + "command_callback_timeout": str(command.callback_timeout), + } + + +# API 平坦 key → (YAML key, 类型转换) 映射 +_API_KEY_MAP: dict[str, tuple[str, ...]] = { + "command_prefix": ("prefix", str), + "command_length_min": ("length_min", int), + "command_length_max": ("length_max", int), + "command_scope": ("scope", str), + "command_list_mode": ("list_mode", str), + "command_at_sender": ("at_sender", "bool"), + "command_callback_url": ("callback_url", str), + "command_callback_timeout": ("callback_timeout", int), + "command_allowed_groups": ("allowed_groups", "csv_list"), + "command_denied_groups": ("denied_groups", "csv_list"), + "command_allowed_users": ("allowed_users", "csv_list"), + "command_denied_users": ("denied_users", "csv_list"), +} + + +def update_settings_from_api(data: dict[str, str]) -> dict[str, str]: + """从管理 API 接收扁平字典,合并到 settings.yaml 并热重载。返回已应用的字段。""" + allowed_keys = set(_API_KEY_MAP.keys()) + filtered = {k: str(v) for k, v in data.items() if k in allowed_keys} + if not filtered: + return {} + + # 读取当前 YAML,合并修改 + yaml_data = load_yaml() + if "command" not in yaml_data: + yaml_data["command"] = {} + cmd = yaml_data["command"] + + for flat_key, value in filtered.items(): + yaml_key, conv = _API_KEY_MAP[flat_key] + if conv == "csv_list": + cmd[yaml_key] = [v.strip() for v in value.split(",") if v.strip()] + elif conv == "bool": + cmd[yaml_key] = value.lower() in ("true", "1", "yes") + elif conv is int: + try: + cmd[yaml_key] = int(value) + except ValueError: + pass + else: + cmd[yaml_key] = value + + save_yaml(yaml_data) + # 写入后立即应用(不等文件 watcher) + _apply_yaml_to_command(yaml_data) + return filtered diff --git a/db.py b/db.py deleted file mode 100644 index 0084843..0000000 --- a/db.py +++ /dev/null @@ -1,82 +0,0 @@ -"""SQLite 数据库层:存储可动态修改的配置项。""" - -import aiosqlite -from pathlib import Path - -DB_PATH = Path(__file__).parent / "data" / "webhook.db" - -# 需要持久化的配置项及其默认值(从 .env 加载时的回退) -SETTINGS_DEFAULTS: dict[str, str] = { - "command_prefix": "#", - "command_length_min": "2", - "command_length_max": "4", - "command_scope": "all", - "command_list_mode": "allow", - "command_allowed_groups": "", - "command_denied_groups": "", - "command_allowed_users": "", - "command_denied_users": "", - "command_at_sender": "true", - "command_callback_url": "", - "command_callback_timeout": "180", -} - - -async def init_db() -> None: - """建表 + 首次启动时写入默认值。""" - DB_PATH.parent.mkdir(parents=True, exist_ok=True) - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - """ - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL DEFAULT '' - ) - """ - ) - await db.commit() - - # 首次启动:插入不存在的默认值 - for key, default in SETTINGS_DEFAULTS.items(): - await db.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", - (key, default), - ) - await db.commit() - - -async def get_setting(key: str) -> str | None: - """读取单个配置值,不存在返回 None。""" - async with aiosqlite.connect(DB_PATH) as db: - cursor = await db.execute("SELECT value FROM settings WHERE key = ?", (key,)) - row = await cursor.fetchone() - return row[0] if row else None - - -async def get_settings() -> dict[str, str]: - """读取全部配置,返回 {key: value} 字典。""" - async with aiosqlite.connect(DB_PATH) as db: - cursor = await db.execute("SELECT key, value FROM settings") - rows = await cursor.fetchall() - return {row[0]: row[1] for row in rows} - - -async def update_setting(key: str, value: str) -> None: - """更新单个配置值。""" - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", - (key, value, value), - ) - await db.commit() - - -async def update_settings(data: dict[str, str]) -> None: - """批量更新配置值。""" - async with aiosqlite.connect(DB_PATH) as db: - for key, value in data.items(): - await db.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", - (key, value, value), - ) - await db.commit() diff --git a/handlers/admin.py b/handlers/admin.py index 421789a..90311a6 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -2,22 +2,7 @@ from aiohttp import web -from ..config import ( - COMMAND_AT_SENDER, - COMMAND_CALLBACK_TIMEOUT, - COMMAND_CALLBACK_URL, - COMMAND_DENIED_GROUPS, - COMMAND_DENIED_USERS, - COMMAND_ALLOWED_GROUPS, - COMMAND_ALLOWED_USERS, - COMMAND_LENGTH_MAX, - COMMAND_LENGTH_MIN, - COMMAND_LIST_MODE, - COMMAND_PREFIX, - COMMAND_SCOPE, - reload_settings, -) -from ..db import get_settings, update_settings +from ..config import command, get_settings_flat, reload_settings, update_settings_from_api from ..response import error, ok @@ -25,8 +10,7 @@ from ..response import error, ok async def api_get_settings(request: web.Request) -> web.Response: """GET /api/settings — 返回全部动态配置。""" - settings = await get_settings() - return ok(data=settings) + return ok(data=get_settings_flat()) async def api_update_settings(request: web.Request) -> web.Response: @@ -39,37 +23,17 @@ async def api_update_settings(request: web.Request) -> web.Response: if not isinstance(data, dict): return error("request body must be a json object") - # 允许更新的 key 白名单 - allowed_keys = { - "command_prefix", - "command_length_min", - "command_length_max", - "command_scope", - "command_list_mode", - "command_allowed_groups", - "command_denied_groups", - "command_allowed_users", - "command_denied_users", - "command_at_sender", - "command_callback_url", - "command_callback_timeout", - } - - filtered = {k: str(v) for k, v in data.items() if k in allowed_keys} + filtered = update_settings_from_api(data) if not filtered: return error("no valid settings to update") - await update_settings(filtered) - reload_settings(filtered) - return ok(data=filtered, msg="配置已更新并生效") async def api_reload_settings(request: web.Request) -> web.Response: - """POST /api/settings/reload — 从数据库重新加载配置。""" - settings = await get_settings() - reload_settings(settings) - return ok(msg="配置已从数据库重新加载") + """POST /api/settings/reload — 从 settings.yaml 重新加载配置。""" + reload_settings() + return ok(msg="配置已从 settings.yaml 重新加载") # ── 管理页面 ───────────────────────────────────────────────── @@ -568,7 +532,7 @@ body {
● 运行中 - +
@@ -750,7 +714,7 @@ body {
存储 - SQLite + YAML
@@ -933,13 +897,13 @@ async function saveAll() { } } -// ── 从数据库重新加载 ────────────────────────────────────── -async function reloadFromDb() { +// ── 从 YAML 重新加载 ────────────────────────────────────── +async function reloadFromYaml() { try { const resp = await fetch('/api/settings/reload', { method: 'POST' }); const json = await resp.json(); if (json.code === 0) { - showToast('已从数据库重新加载', 'success'); + showToast('已从 YAML 重新加载', 'success'); await loadSettings(); } else { showToast('重新加载失败: ' + json.msg, 'error'); diff --git a/handlers/command.py b/handlers/command.py index d73a4b9..9e47e9d 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -4,7 +4,7 @@ import re import aiohttp -from ..config import COMMAND_AT_SENDER, COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR +from ..config import UPLOAD_DIR, command from ..handlers.message import _resolve_url @@ -18,7 +18,7 @@ def build_command_pattern() -> re.Pattern: - 其他非空白字符 = 1 """ return re.compile( - rf"^{re.escape(COMMAND_PREFIX)}(\S{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$", + rf"^{re.escape(command.prefix)}(\S{{{command.length_min},{command.length_max}}})(?:\s+(.+))?$", re.DOTALL, ) @@ -56,28 +56,28 @@ async def send_command_callback(data: dict, event, api, logger) -> None: {"type": "file", "url": "..."}, {"type": "video", "url": "..."} ], - "at_sender": true // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊) + "at_sender": true // 是否 @发送者(默认取配置,仅群聊) } 所有字段均为可选,无回复内容时返回空 JSON 即可。 回复会引用触发命令的原消息。 """ - if not COMMAND_CALLBACK_URL: - logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调") + if not command.callback_url: + logger.warning("callback_url 未配置,跳过命令回调") return try: async with aiohttp.ClientSession() as session: async with session.post( - COMMAND_CALLBACK_URL, + command.callback_url, json=data, - timeout=aiohttp.ClientTimeout(total=COMMAND_CALLBACK_TIMEOUT), + timeout=aiohttp.ClientTimeout(total=command.callback_timeout), ) as resp: if resp.status >= 400: body = await resp.text() logger.error( "命令回调失败: status=%d url=%s body=%s", - resp.status, COMMAND_CALLBACK_URL, body[:200], + resp.status, command.callback_url, body[:200], ) return @@ -93,13 +93,13 @@ async def send_command_callback(data: dict, event, api, logger) -> None: await _handle_reply(result, event.data, api, logger) except Exception as exc: - logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc) + logger.error("命令回调异常: url=%s error=%s", command.callback_url, exc) async def _handle_reply(result: dict, msg_event, api, logger) -> None: """处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。""" # at_sender: 回调响应中的值优先,未指定则使用全局配置 - at_sender = result.get("at_sender", COMMAND_AT_SENDER) + at_sender = result.get("at_sender", command.at_sender) messages = result.get("messages") reply = result.get("reply") group_id = getattr(msg_event, "group_id", None) diff --git a/plugin.py b/plugin.py index c71e07f..4df7ab3 100644 --- a/plugin.py +++ b/plugin.py @@ -8,24 +8,15 @@ from aiohttp import web from ncatbot.plugin import NcatBotPlugin from .config import ( - COMMAND_ALLOWED_GROUPS, - COMMAND_ALLOWED_USERS, - COMMAND_AT_SENDER, - COMMAND_CALLBACK_URL, - COMMAND_DENIED_GROUPS, - COMMAND_DENIED_USERS, - COMMAND_LENGTH_MAX, - COMMAND_LENGTH_MIN, - COMMAND_LIST_MODE, - COMMAND_PREFIX, - COMMAND_SCOPE, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY, + SETTINGS_YAML_PATH, + command, + ensure_settings_yaml, reload_settings, ) -from .db import get_settings, init_db from .handlers.admin import admin_page_handler, api_get_settings, api_reload_settings, api_update_settings from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler @@ -47,12 +38,12 @@ class WebHookPlugin(NcatBotPlugin): self._webhook_runner: web.AppRunner | None = None self._cleanup_task: asyncio.Task | None = None self._listener_task: asyncio.Task | None = None + self._watcher_task: asyncio.Task | None = None async def on_load(self): - # 初始化数据库并加载动态配置 - await init_db() - settings = await get_settings() - reload_settings(settings) + # 初始化 settings.yaml 并加载配置 + ensure_settings_yaml() + reload_settings() self.logger.info("Webhook 插件已加载") self.logger.info( @@ -61,32 +52,28 @@ class WebHookPlugin(NcatBotPlugin): ) self.logger.info( "命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s", - COMMAND_PREFIX, - COMMAND_LENGTH_MIN, - COMMAND_LENGTH_MAX, - COMMAND_SCOPE, - COMMAND_LIST_MODE, - COMMAND_CALLBACK_URL or "未配置", + command.prefix, + command.length_min, + command.length_max, + command.scope, + command.list_mode, + command.callback_url or "未配置", ) asyncio.create_task(self._start_webhook()) self._cleanup_task = asyncio.create_task(self._cleanup_loop()) self._listener_task = asyncio.create_task(self._message_listener()) + self._watcher_task = asyncio.create_task(self._config_watcher()) async def on_close(self): - if self._listener_task is not None: - self._listener_task.cancel() - try: - await self._listener_task - except asyncio.CancelledError: - pass - self._listener_task = None - if self._cleanup_task is not None: - self._cleanup_task.cancel() - try: - await self._cleanup_task - except asyncio.CancelledError: - pass - self._cleanup_task = None + for task_attr in ("_watcher_task", "_listener_task", "_cleanup_task"): + task = getattr(self, task_attr) + if task is not None: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + setattr(self, task_attr, None) await self._stop_webhook() self.logger.info("Webhook 插件已卸载") @@ -99,6 +86,21 @@ class WebHookPlugin(NcatBotPlugin): except Exception as exc: self.logger.error("清理过期文件失败: %s", exc) + async def _config_watcher(self) -> None: + """轮询 settings.yaml 的 mtime,变更时热重载配置。""" + last_mtime: float = 0.0 + while True: + await asyncio.sleep(2) + try: + if SETTINGS_YAML_PATH.exists(): + current_mtime = SETTINGS_YAML_PATH.stat().st_mtime + if current_mtime != last_mtime: + last_mtime = current_mtime + reload_settings() + self.logger.info("settings.yaml changed, config reloaded") + except Exception as exc: + self.logger.error("Config watcher error: %s", exc) + async def _message_listener(self) -> None: """监听 QQ 消息,匹配命令模式后转发到外部回调。""" try: @@ -114,25 +116,25 @@ class WebHookPlugin(NcatBotPlugin): # 范围过滤:group / private / all is_group = hasattr(event.data, "group_id") - if COMMAND_SCOPE == "group" and not is_group: + if command.scope == "group" and not is_group: continue - if COMMAND_SCOPE == "private" and is_group: + if command.scope == "private" and is_group: continue # 黑白名单过滤 - if COMMAND_LIST_MODE == "allow": + if command.list_mode == "allow": # 白名单模式:在名单内才放行 - if COMMAND_ALLOWED_GROUPS and is_group: - if event.data.group_id not in COMMAND_ALLOWED_GROUPS: + if command.allowed_groups and is_group: + if event.data.group_id not in command.allowed_groups: continue - if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: + if command.allowed_users and event.data.user_id not in command.allowed_users: continue - elif COMMAND_LIST_MODE == "deny": + elif command.list_mode == "deny": # 黑名单模式:在名单内则拒绝 - if COMMAND_DENIED_GROUPS and is_group: - if event.data.group_id in COMMAND_DENIED_GROUPS: + if command.denied_groups and is_group: + if event.data.group_id in command.denied_groups: continue - if COMMAND_DENIED_USERS and event.data.user_id in COMMAND_DENIED_USERS: + if command.denied_users and event.data.user_id in command.denied_users: continue # 构建回调数据 @@ -188,4 +190,4 @@ class WebHookPlugin(NcatBotPlugin): if self._webhook_runner is not None: await self._webhook_runner.cleanup() self._webhook_runner = None - self.logger.info("Webhook 已停止") + self.logger.info("Webhook 已停止") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index eeb1c3a..83028e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,5 @@ dependencies = [ "ncatbot5>=5.5.2.post3", "aiohttp>=3.9", "python-dotenv>=1.0", - "aiosqlite>=0.20", + "pyyaml>=6.0", ] diff --git a/uv.lock b/uv.lock index 137f749..be4f8da 100644 --- a/uv.lock +++ b/uv.lock @@ -109,15 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -918,17 +909,17 @@ version = "0.1.1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, - { name = "aiosqlite" }, { name = "ncatbot5" }, { name = "python-dotenv" }, + { name = "pyyaml" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.9" }, - { name = "aiosqlite", specifier = ">=0.20" }, { name = "ncatbot5", specifier = ">=5.5.2.post3" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [[package]] -- 2.49.1 From 58e53c8aec2cb425542396cb27b9a4d6c0300acc Mon Sep 17 00:00:00 2001 From: zhilv Date: Sun, 3 May 2026 21:56:48 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E9=BB=91?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E5=BC=80=E5=85=B3=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=BF=9D=E5=AD=98=E4=B8=8E=E7=AE=A1=E7=90=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 list_enabled 开关控制是否启用名单过滤 - 表单变更后 800ms 自动保存,去掉手动保存按钮 - Header 显示"未保存"指示器,保存中 toast 提示 - 内容区限制最大宽度 900px,优化宽屏显示 - 侧边栏增加圆角选中态,运行状态带脉冲动画 - 白名单模式灰掉黑名单输入,关闭名单时显示遮罩 - 命令测试结果增加成功/失败颜色反馈 - 回调格式改用等宽字体代码块 --- config.py | 14 + handlers/admin.py | 804 +++++++++++++++++++++++++--------------------- plugin.py | 28 +- 3 files changed, 470 insertions(+), 376 deletions(-) diff --git a/config.py b/config.py index 6d846cd..4c77428 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,7 @@ class CommandConfig: length_min: int = 2 length_max: int = 4 scope: str = "all" # all / group / private + list_enabled: bool = False # 是否启用黑白名单过滤 list_mode: str = "allow" # allow / deny allowed_groups: frozenset[str] = frozenset() denied_groups: frozenset[str] = frozenset() @@ -62,6 +63,7 @@ _ENV_DEFAULTS: dict = { "length_min": int(os.environ.get("COMMAND_LENGTH_MIN", "2")), "length_max": int(os.environ.get("COMMAND_LENGTH_MAX", "4")), "scope": os.environ.get("COMMAND_SCOPE", "all"), + "list_enabled": os.environ.get("COMMAND_LIST_ENABLED", "false").lower() in ("true", "1", "yes"), "list_mode": os.environ.get("COMMAND_LIST_MODE", "allow"), "allowed_groups": os.environ.get("COMMAND_ALLOWED_GROUPS", ""), "denied_groups": os.environ.get("COMMAND_DENIED_GROUPS", ""), @@ -87,6 +89,7 @@ def _yaml_defaults() -> dict: "length_min": 2, "length_max": 4, "scope": "all", + "list_enabled": False, "list_mode": "allow", "allowed_groups": [], "denied_groups": [], @@ -127,6 +130,7 @@ def ensure_settings_yaml() -> None: "length_max": ("COMMAND_LENGTH_MAX", int), "scope": ("COMMAND_SCOPE", str), "list_mode": ("COMMAND_LIST_MODE", str), + "list_enabled": ("COMMAND_LIST_ENABLED", "bool"), "allowed_groups": ("COMMAND_ALLOWED_GROUPS", "csv_list"), "denied_groups": ("COMMAND_DENIED_GROUPS", "csv_list"), "allowed_users": ("COMMAND_ALLOWED_USERS", "csv_list"), @@ -158,6 +162,14 @@ def _apply_yaml_to_command(data: dict) -> None: command.length_max = int(cmd_data.get("length_max", _ENV_DEFAULTS["length_max"])) command.scope = str(cmd_data.get("scope", _ENV_DEFAULTS["scope"])) command.list_mode = str(cmd_data.get("list_mode", _ENV_DEFAULTS["list_mode"])) + + # list_enabled: YAML bool → Python bool + list_enabled_val = cmd_data.get("list_enabled", _ENV_DEFAULTS["list_enabled"]) + if isinstance(list_enabled_val, str): + command.list_enabled = list_enabled_val.lower() in ("true", "1", "yes") + else: + command.list_enabled = bool(list_enabled_val) + command.callback_url = str(cmd_data.get("callback_url", _ENV_DEFAULTS["callback_url"])) command.callback_timeout = int(cmd_data.get("callback_timeout", _ENV_DEFAULTS["callback_timeout"])) @@ -210,6 +222,7 @@ def get_settings_flat() -> dict[str, str]: "command_length_max": str(command.length_max), "command_scope": command.scope, "command_list_mode": command.list_mode, + "command_list_enabled": str(command.list_enabled).lower(), "command_allowed_groups": ",".join(sorted(command.allowed_groups)), "command_denied_groups": ",".join(sorted(command.denied_groups)), "command_allowed_users": ",".join(sorted(command.allowed_users)), @@ -227,6 +240,7 @@ _API_KEY_MAP: dict[str, tuple[str, ...]] = { "command_length_max": ("length_max", int), "command_scope": ("scope", str), "command_list_mode": ("list_mode", str), + "command_list_enabled": ("list_enabled", "bool"), "command_at_sender": ("at_sender", "bool"), "command_callback_url": ("callback_url", str), "command_callback_timeout": ("callback_timeout", int), diff --git a/handlers/admin.py b/handlers/admin.py index 90311a6..de3396b 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -59,10 +59,11 @@ ADMIN_HTML = r""" --success: #52C41A; --warning: #FAAD14; --danger: #FF4D4F; - --sidebar-width: 220px; - --header-height: 56px; + --sidebar-width: 200px; + --header-height: 52px; --radius: 6px; --shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + --content-max-width: 900px; } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -85,66 +86,63 @@ body { display: flex; flex-direction: column; z-index: 100; - transition: width 0.2s; } .sidebar-logo { height: var(--header-height); display: flex; align-items: center; - padding: 0 20px; + padding: 0 16px; color: #fff; - font-size: 16px; + font-size: 15px; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,0.08); - white-space: nowrap; - overflow: hidden; } .sidebar-logo .logo-icon { - width: 28px; height: 28px; + width: 26px; height: 26px; background: var(--primary-color); border-radius: 6px; display: flex; align-items: center; justify-content: center; margin-right: 10px; - font-size: 14px; + font-size: 13px; flex-shrink: 0; } -.sidebar-nav { flex: 1; padding: 8px 0; overflow-y: auto; } +.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; } .nav-group-title { - padding: 16px 20px 6px; + padding: 12px 16px 4px; font-size: 11px; color: rgba(255,255,255,0.35); text-transform: uppercase; - letter-spacing: 1px; + letter-spacing: 0.5px; } .nav-item { display: flex; align-items: center; - padding: 10px 20px; + padding: 12px 16px; + margin: 2px 8px; + border-radius: 6px; color: rgba(255,255,255,0.65); cursor: pointer; transition: all 0.15s; - font-size: 14px; - border-left: 3px solid transparent; + font-size: 13px; } .nav-item:hover { color: #fff; - background: rgba(255,255,255,0.06); + background: rgba(255,255,255,0.08); } .nav-item.active { color: #fff; - background: rgba(22,119,255,0.15); - border-left-color: var(--primary-color); + background: var(--primary-color); } .nav-item .nav-icon { width: 18px; margin-right: 10px; - text-align: center; font-size: 15px; + text-align: center; font-size: 14px; flex-shrink: 0; } @@ -157,7 +155,7 @@ body { min-height: 100vh; } -/* Header */ +/* Header - sticky with unsaved indicator */ .header { height: var(--header-height); background: var(--card-bg); @@ -170,9 +168,12 @@ body { } .header-title { - font-size: 16px; + font-size: 15px; font-weight: 500; color: var(--text-main); + display: flex; + align-items: center; + gap: 8px; } .header-right { @@ -182,18 +183,52 @@ body { gap: 12px; } -.header-badge { +/* Status badge with pulse animation */ +.status-badge { + display: flex; + align-items: center; + gap: 6px; font-size: 12px; - padding: 2px 8px; - border-radius: 10px; - background: rgba(22,119,255,0.1); - color: var(--primary-color); + padding: 4px 10px; + border-radius: 12px; + background: rgba(82,196,26,0.1); + color: var(--success); } -/* Content */ +.status-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--success); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.2); } +} + +/* Unsaved indicator */ +.unsaved-indicator { + font-size: 12px; + color: var(--warning); + display: none; + align-items: center; + gap: 4px; +} + +.unsaved-indicator.show { display: flex; } + +/* Content - with max width */ .content { flex: 1; padding: 24px; + display: flex; + justify-content: center; +} + +.content-inner { + width: 100%; + max-width: var(--content-max-width); } /* Card */ @@ -201,11 +236,11 @@ body { background: var(--card-bg); border-radius: var(--radius); box-shadow: var(--shadow); - margin-bottom: 20px; + margin-bottom: 16px; } .card-header { - padding: 16px 20px; + padding: 14px 18px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; @@ -213,99 +248,110 @@ body { } .card-header h3 { - font-size: 15px; + font-size: 14px; font-weight: 500; } -.card-body { padding: 20px; } +.card-body { padding: 18px; } /* Form */ .form-group { - margin-bottom: 18px; + margin-bottom: 16px; } .form-group:last-child { margin-bottom: 0; } .form-label { - display: flex; - align-items: center; + display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; color: var(--text-main); } -.form-label .label-hint { - margin-left: 6px; +.form-hint { font-weight: 400; color: var(--text-light); font-size: 12px; + margin-left: 4px; } -.form-input, .form-select, .form-textarea { +.form-input, .form-select { width: 100%; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: var(--radius); - font-size: 14px; + font-size: 13px; color: var(--text-main); background: #fff; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color 0.15s, box-shadow 0.15s, opacity 0.2s; outline: none; } -.form-input:focus, .form-select:focus, .form-textarea:focus { +.form-input:focus, .form-select:focus { border-color: var(--primary-color); - box-shadow: 0 0 0 2px rgba(22,119,255,0.15); + box-shadow: 0 0 0 2px rgba(22,119,255,0.12); } -.form-textarea { - min-height: 80px; - resize: vertical; - font-family: inherit; +.form-input::placeholder { color: var(--text-light); } + +.form-input:disabled, .form-select:disabled { + background: #f5f5f5; + color: var(--text-light); + cursor: not-allowed; + opacity: 0.6; } .form-row { - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; gap: 16px; } -.form-row .form-group { flex: 1; } +@media (max-width: 600px) { + .form-row { grid-template-columns: 1fr; } +} /* Tag input */ .tag-container { display: flex; flex-wrap: wrap; gap: 6px; - padding: 6px 10px; + padding: 8px 10px; border: 1px solid var(--border-color); border-radius: var(--radius); - min-height: 38px; + min-height: 42px; cursor: text; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color 0.15s, box-shadow 0.15s, opacity 0.2s, background 0.2s; background: #fff; } .tag-container:focus-within { border-color: var(--primary-color); - box-shadow: 0 0 0 2px rgba(22,119,255,0.15); + box-shadow: 0 0 0 2px rgba(22,119,255,0.12); +} + +.tag-container.disabled { + background: #f5f5f5; + opacity: 0.6; + pointer-events: none; } .tag-item { display: inline-flex; align-items: center; - padding: 2px 8px; - background: rgba(22,119,255,0.08); + padding: 3px 8px; + background: rgba(22,119,255,0.1); color: var(--primary-color); border-radius: 4px; - font-size: 13px; + font-size: 12px; gap: 4px; } .tag-item .tag-close { cursor: pointer; font-size: 14px; - opacity: 0.6; + opacity: 0.5; line-height: 1; } @@ -315,60 +361,27 @@ body { border: none; outline: none; flex: 1; - min-width: 80px; - font-size: 14px; + min-width: 100px; + font-size: 13px; padding: 2px 0; color: var(--text-main); + background: transparent; } -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 7px 16px; - border-radius: var(--radius); - font-size: 14px; - cursor: pointer; - border: 1px solid transparent; - transition: all 0.15s; - gap: 6px; - line-height: 1.4; -} - -.btn-primary { - background: var(--primary-color); - color: #fff; -} -.btn-primary:hover { background: var(--primary-hover); } - -.btn-default { - background: #fff; - border-color: var(--border-color); - color: var(--text-main); -} -.btn-default:hover { border-color: var(--primary-color); color: var(--primary-color); } - -.btn-danger { - background: #fff; - border-color: var(--danger); - color: var(--danger); -} -.btn-danger:hover { background: var(--danger); color: #fff; } - -.btn-sm { padding: 4px 10px; font-size: 12px; } +.tag-input::placeholder { color: var(--text-light); } /* Switch */ .switch-wrapper { display: flex; align-items: center; - gap: 8px; + gap: 10px; } .switch { position: relative; - width: 40px; height: 22px; + width: 38px; height: 22px; cursor: pointer; + flex-shrink: 0; } .switch input { display: none; } @@ -391,34 +404,63 @@ body { transition: 0.2s; } -.switch input:checked + .switch-slider { - background: var(--primary-color); -} - -.switch input:checked + .switch-slider::before { - transform: translateX(18px); -} +.switch input:checked + .switch-slider { background: var(--primary-color); } +.switch input:checked + .switch-slider::before { transform: translateX(16px); } .switch-text { font-size: 13px; color: var(--text-secondary); } -/* Status dot */ -.status-dot { - width: 8px; height: 8px; - border-radius: 50%; - display: inline-block; - margin-right: 6px; +/* Code block */ +.code-block { + font-family: "SF Mono", Monaco, "Cascadia Code", Consolas, monospace; + font-size: 12px; + background: #f6f8fa; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px 12px; + overflow-x: auto; + line-height: 1.5; + color: #24292f; +} + +/* Info block */ +.info-block { + padding: 12px 14px; + background: #f0f5ff; + border-radius: var(--radius); + border-left: 3px solid var(--primary-color); + margin-bottom: 16px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; +} + +/* Section disabled overlay */ +.section-disabled { + position: relative; +} + +.section-disabled::after { + content: '名单功能已关闭'; + position: absolute; + inset: 0; + background: rgba(255,255,255,0.7); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: var(--text-light); + border-radius: var(--radius); + pointer-events: none; } -.status-dot.on { background: var(--success); } -.status-dot.off { background: var(--text-light); } /* Toast */ .toast-container { position: fixed; - top: 16px; - right: 16px; + top: 12px; + right: 12px; z-index: 9999; display: flex; flex-direction: column; @@ -426,20 +468,20 @@ body { } .toast { - padding: 10px 16px; + padding: 10px 14px; border-radius: var(--radius); - font-size: 14px; + font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.12); display: flex; align-items: center; gap: 8px; animation: slideIn 0.2s ease; - min-width: 240px; } .toast-success { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; } .toast-error { background: #fff2f0; color: #cf1322; border: 1px solid #ffccc7; } .toast-info { background: #e6f7ff; color: #0958d9; border: 1px solid #91caff; } +.toast-saving { background: #fffbe6; color: #d48806; border: 1px solid #ffe58f; } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } @@ -449,61 +491,20 @@ body { /* Responsive */ @media (max-width: 768px) { .sidebar { width: 0; overflow: hidden; } - .sidebar.open { width: var(--sidebar-width); } .main { margin-left: 0; } - .form-row { flex-direction: column; gap: 0; } -} - -/* Tabs */ -.tabs { - display: flex; - border-bottom: 1px solid var(--border-color); - margin-bottom: 0; -} - -.tab-item { - padding: 12px 20px; - font-size: 14px; - color: var(--text-secondary); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.15s; -} - -.tab-item:hover { color: var(--primary-color); } - -.tab-item.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - font-weight: 500; -} - -.tab-panel { display: none; } -.tab-panel.active { display: block; } - -/* Info block */ -.info-block { - padding: 12px 16px; - background: #f0f5ff; - border-radius: var(--radius); - border-left: 3px solid var(--primary-color); - margin-bottom: 16px; - font-size: 13px; - color: var(--text-secondary); - line-height: 1.6; + .content { padding: 16px; } } - -