diff --git a/CHANGELOG.md b/CHANGELOG.md index d157627..17e0fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ ## 更新记录 +### version: 3.4.3(2026-09-05): + +重构功能帮助图片:由不规则卡片区块改为统一的“功能分区 + 指令列表 + 说明”三列结构,每条指令行预留默认空白的说明区域,并整体放大分区、功能名、指令及辅助文字;按照当前 `command_map` 校正 18 个分区和 92 个真实触发词,角色记录指令由“聊天”更新为“发言”,同时补入“小药”“骗子”并移除未注册的“副本”指令。 + +新增基于 WebUI 与 SQLite 的“会话控制”:默认使用“全部会话”模式,也可切换为白名单或黑名单,并维护任意 AstrBot 会话 ID 及备注。访问策略同时作用于插件指令与实时事件推送;空白名单不放行任何会话,空黑名单放行全部会话。 + +整理 WebUI 后端结构:新增独立的 `core/webui.py` 服务,统一负责管理页路由注册、Dashboard 数据聚合、请求校验和保存、删除、恢复接口;`main.py` 仅保留服务构造与注册调用,现有 Web API 路径和前端调用协议不变。 + +插件 WebUI 的区服别名和心法别名页新增“恢复默认”操作;采用页面内可见的二次点击确认,不依赖嵌入页面可能无法显示的浏览器原生确认框。确认后分别读取随插件分发的 `data/server_aliases.json` 和 `data/kungfu.json`,在事务中完整重写对应数据库表并刷新页面数据。 + +移除 WebUI 顶部统计区域;“会话控制”移动为第一个默认页签,并新增醒目的当前生效模式状态。模式卡片明确区分当前模式与尚未保存的选择,切换按钮同步显示目标模式。“会话绑定”更名为“区服绑定”并保留在“事件推送”之后。 + +重构 WebUI 区服绑定交互:顶部入口改为“添加绑定”,会话 ID 可自定义但绑定区服只能从标准区服下拉框选择;列表编辑时会话 ID 保持只读,仅在对应绑定区服列内切换下拉框进行保存,解除绑定改为点击后直接删除对应记录。Web API 同步拒绝别名及任意文本作为绑定区服。 + ### version: 3.4.2(2026-09-04): 图片模板整体调整为浅色背景、深色高对比文本和更大的字号,缩小内容与金色边框之间的留白并扩大主体区域;同步检查并调整多个独立页面的字号、间距及表格布局,提高长图阅读体验。 diff --git a/README.md b/README.md index cd13518..dd94590 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## 功能特点 -- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、副本、家园、社区等场景。 +- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、百战、家园、社区等场景。 - 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。 - 支持 `宏`、`资历` 以及帮会、阵营、其他排行榜的 10 秒两轮交互,超时默认选择第一项。 - 查询图片统一使用浅色高对比主题,并可在插件配置页调整渲染清晰度、输出格式和 JPEG 质量。 @@ -231,7 +231,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通 | 指令 | 说明与输出 | 凭据 | | --- | --- | --- | -| `聊天 服务器 角色 [条数] [页数]` | 角色聊天记录,默认 20 条、第 1 页;图片 | Token | +| `发言 服务器 角色 [条数] [页数]` | 角色发言记录,默认 20 条、第 1 页;图片 | Token | | `统战 [服务器]` | 统战频道统计;文本 | 无 | | `小药 [心法]` | 小吃小药推荐;图片 | 无 | | `骗子 UID [服务器]` | 查询欺诈记录;文本 | Token | @@ -274,7 +274,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通 | `避雷修改 ID 名称 备注` | 按 ID 更新记录,同时覆盖修改时间和修改人 | | `避雷删除 ID` | 按 ID 删除记录 | -避雷功能没有内置权限校验。只要能够触发插件指令,当前实现就允许新增、修改和删除记录;公开群聊部署时应在 AstrBot 或消息平台层配置访问控制。 +避雷功能没有会话内的用户角色校验。在“会话控制”放行的会话中,只要能够触发插件指令,当前实现就允许新增、修改和删除记录;如需限制到指定管理员,仍应在 AstrBot 或消息平台层配置权限。 ### 实时事件推送 @@ -291,7 +291,9 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通 ### WebUI 插件管理 -AstrBot 插件详情页中的“剑网三插件管理”页面提供四个页签:会话绑定、事件订阅、区服别名和心法别名。页面可以新增、修改或解除任意会话绑定,查看所有会话的事件总开关及订阅编号。区服别名页展示完整标准区服目录,标准名称只读,只能行内编辑别名;心法别名页隐藏 JX3BOX 配装 ID,标准心法名称只读,同样只允许行内编辑最多 5 个别名。管理页通过 AstrBot Plugin Pages 桥接调用插件 Web API,不直接访问 Dashboard 凭据。 +AstrBot 插件详情页中的“剑网三插件管理”页面提供五个页签:会话控制、事件推送、区服绑定、区服别名和心法别名,不再显示顶部统计区域。会话控制为默认页签,页面会明确标出当前真正生效的模式,并区分尚未保存的模式选择。该功能默认使用“全部会话”,也可切换为白名单或黑名单;会话 ID 可从已有绑定和订阅记录中选择,也可直接输入,并可维护名单类型和备注。空白名单不放行任何会话,空黑名单放行全部会话,名单策略同时作用于插件指令和实时事件推送。 + +区服绑定页可使用自定义会话 ID 添加记录,绑定区服只能从标准区服下拉框选择。编辑已有记录时会话 ID 保持只读,只在绑定区服列内修改,点击解除绑定会直接删除该行。事件推送页显示所有会话的总开关及订阅编号。区服别名页展示完整标准区服目录,标准名称只读,只能行内编辑别名;心法别名页隐藏 JX3BOX 配装 ID,标准心法名称只读,同样只允许行内编辑最多 5 个别名。两个别名页均可使用随插件分发的 JSON 种子完整覆盖数据库并恢复默认配置。管理页通过 AstrBot Plugin Pages 桥接调用插件 Web API,不直接访问 Dashboard 凭据。 ## 业务流程 @@ -299,7 +301,9 @@ AstrBot 插件详情页中的“剑网三插件管理”页面提供四个页签 flowchart LR A["AstrBot 消息事件"] --> B["前缀检查与空白切分"] B --> C["command_map 查找处理器"] - C --> D["MessageBuilder 参数适配"] + C --> K{"会话控制是否放行"} + K -->|是| D["MessageBuilder 参数适配"] + K -->|否| X["停止处理"] D --> E1["JX3APIService"] D --> E2["JX3BOXService"] D --> E3["BiLeidata / EventPushService / KungfuAliasService"] @@ -322,13 +326,15 @@ flowchart LR 1. 读取前缀、Token、Ticket 和 WebSocket 配置。 2. 计算插件目录、AstrBot 数据目录、本地 SQLite 和模板目录路径。 3. 把 `templates/img`、`templates/img/sand`、`templates/sect`、`templates/serendipity` 中的图片读取为 base64 Data URL。 -4. 创建本地数据库、两个数据服务、心法别名服务、避雷服务、事件推送服务和消息构建器。 +4. 创建本地数据库、两个数据服务、心法别名服务、会话控制服务、避雷服务、事件推送服务、WebUI 服务和消息构建器。 异步初始化阶段会连接数据库并创建以下本地表: - `bilei`:避雷记录。 - `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。 - `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。 +- `session_control_settings`:保存当前会话控制模式,首次初始化默认为 `all`。 +- `session_control_entries`:保存白名单或黑名单会话 ID 及备注。 - `server_aliases`:保存标准区服名及其 JSON 别名列表。 - `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。 - `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。 @@ -431,7 +437,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲 | 文件 | 生命周期 | 内容 | | --- | --- | --- | -| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 心法与别名、避雷记录、会话区服绑定、区服别名、事件订阅及基础数据缓存 | +| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 心法与别名、避雷记录、会话控制、会话区服绑定、区服别名、事件订阅及基础数据缓存 | | `data/kungfu.json` | 随插件分发,只在初始化时读取 | 32 条默认心法、别名和 JX3BOX 配装 ID | | `data/server_aliases.json` | 随插件分发,只在初始化时读取 | 默认标准区服及其别名 | @@ -441,7 +447,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲 `core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。当前支持官方 `SocketEventMap` 中的全部 39 种事件,包含奇遇、马驹、扶摇、烟花、的卢、玄晶、阵营、宣战、据点、攻防拍卖与免费资讯事件。 -每条消息按 `action` 识别事件,正文优先读取 `detail`,同时兼容 `data`。分发时联查会话区服绑定:正文包含 `server` 时仅保留同区服或未绑定区服的接收会话,正文不含 `server` 时不做区服过滤。之后只有会话的 `enabled=1` 且对应 `action_编号=1` 时才发送。会话 ID 直接取当前消息的 `unified_msg_origin`。 +每条消息按 `action` 识别事件,正文优先读取 `detail`,同时兼容 `data`。分发时先应用会话控制策略,再联查会话区服绑定:正文包含 `server` 时仅保留同区服或未绑定区服的接收会话,正文不含 `server` 时不做区服过滤。之后只有会话的 `enabled=1` 且对应 `action_编号=1` 时才发送。会话 ID 直接取当前消息的 `unified_msg_origin`。 表中使用 `action_1001` 这类列名保存各事件开关,并在新增事件编号时自动为旧数据库补列。升级时旧的 `tuishong` 轮询状态表会被删除。 @@ -465,6 +471,8 @@ astrbot_plugin_jx3/ │ ├── message.py # 文本、图片、消息链和两轮会话构建 │ ├── request.py # aiohttp 请求封装 │ ├── event_push.py # WebSocket 事件通道与会话订阅 +│ ├── session_control.py # 全部会话、白名单与黑名单访问策略 +│ ├── webui.py # 插件管理页路由、数据聚合与请求处理 │ ├── server_binding.py # 会话区服绑定、别名与区服目录 │ ├── kungfu_alias.py # 本地心法目录初始化、校验与 WebUI 写入 │ ├── bilei_data.py # 避雷数据增删改查 @@ -512,16 +520,17 @@ git diff --check ## 当前版本状态 -以下内容是对 v3.4.2 当前源码的静态核对结果,部署和二次开发前应注意: +以下内容是对 v3.4.3 当前源码的静态核对结果,部署和二次开发前应注意: 1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。 2. `帮会排行`、`阵营排行`、`其他排行` 使用固定的“数字序号 → 榜单名称”首轮菜单,次轮查询统一截取接口返回的前 50 条;取得有效选择后会先停止超时计时器,避免渲染期间重复输出。 -3. 区服别名 WebUI 展示完整标准区服目录并只允许行内编辑别名;心法别名 WebUI 隐藏配装 ID,后端保存接口也只更新已有心法的别名。 -4. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况。 -5. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`。 -6. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces`、`seniority` 和 `score/totalScore` 渲染,JX3BOX 不再参与资历查询。 -7. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。 -8. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。 +3. 区服绑定 WebUI 的新增及行内编辑均只接受标准区服,聊天指令仍可使用区服别名;区服别名页展示完整标准区服目录并只允许行内编辑别名,心法别名页隐藏配装 ID,后端保存接口也只更新已有心法的别名。“恢复默认”使用页面内二次点击确认,确认后会用随包 JSON 完整覆盖对应数据库表,当前自定义别名无法从页面撤销恢复。 +4. 会话控制默认放行全部会话;切换白名单后只有白名单记录可执行插件指令及接收推送,切换黑名单后仅拦截黑名单记录。名单记录会保留在数据库中,切换模式不会删除已有配置。 +5. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况。 +6. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`。 +7. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces`、`seniority` 和 `score/totalScore` 渲染,JX3BOX 不再参与资历查询。 +8. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。 +9. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。 ## 注意事项 diff --git a/core/event_push.py b/core/event_push.py index de1ffdc..da0e5fd 100644 --- a/core/event_push.py +++ b/core/event_push.py @@ -15,6 +15,7 @@ from astrbot.api.star import Context from .sqlite import AsyncSQLiteDB from .server_binding import ServerBindingService +from .session_control import SessionControlService DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" @@ -294,11 +295,13 @@ class EventPushService: config: AstrBotConfig, sqlite: AsyncSQLiteDB, server_binding: ServerBindingService, + session_control: SessionControlService, ): self.context = context self.config = config self.sql = sqlite self.server_binding = server_binding + self.session_control = session_control self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip() self.token = str(config.get("jx3api_wss_token", "") or "").strip() self._runner: Optional[asyncio.Task] = None @@ -520,6 +523,7 @@ class EventPushService: return [ (str(row["session_id"]), str(row.get("server") or "").strip()) for row in rows + if self.session_control.is_allowed(row["session_id"]) ] async def list_subscription_statuses(self) -> list[dict[str, Any]]: diff --git a/core/kungfu_alias.py b/core/kungfu_alias.py index 0c3a02b..b113b99 100644 --- a/core/kungfu_alias.py +++ b/core/kungfu_alias.py @@ -31,6 +31,18 @@ class KungfuAliasService: await self._seed_defaults() async def _seed_defaults(self): + records = self._load_seed_defaults() + for values in records: + await self.sql.execute( + """ + INSERT OR IGNORE INTO kungfu + (pzid, name, name1, name2, name3, name4, name5) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + values, + ) + + def _load_seed_defaults(self) -> list[tuple[Any, ...]]: try: records = json.loads(self.seed_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: @@ -39,21 +51,40 @@ class KungfuAliasService: if not isinstance(records, list): raise RuntimeError("心法种子数据必须是数组") + normalized_records: list[tuple[Any, ...]] = [] + seen_pzids: set[int] = set() for record in records: if not isinstance(record, dict): continue pzid = self._parse_pzid(record.get("pzid")) + if pzid in seen_pzids: + continue + seen_pzids.add(pzid) name = self._clean(record.get("name")) aliases = self._normalize_aliases(name, record.get("aliases") or []) values = [*aliases, *([None] * (self.MAX_ALIASES - len(aliases)))] - await self.sql.execute( + normalized_records.append((pzid, name, *values)) + return normalized_records + + async def restore_defaults(self) -> int: + """使用随插件分发的 JSON 种子完整重写心法表。""" + records = self._load_seed_defaults() + statements: list[tuple[str, tuple[Any, ...]]] = [ + ("DELETE FROM kungfu", ()), + ] + statements.extend( + ( """ - INSERT OR IGNORE INTO kungfu + INSERT INTO kungfu (pzid, name, name1, name2, name3, name4, name5) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (pzid, name, *values), + values, ) + for values in records + ) + await self.sql.execute_transaction(statements) + return len(records) async def list_kungfu(self) -> list[dict[str, Any]]: rows = await self.sql.fetch_all( diff --git a/core/message.py b/core/message.py index 785a919..51549af 100644 --- a/core/message.py +++ b/core/message.py @@ -486,7 +486,7 @@ class MessageBuilder: return await self.T2I_image_msg(event, lambda: self.jx3api.qixue(name,0)) async def liaotian(self, event: AstrMessageEvent, server:str, name: str, limit:int = 20, page:int = 1): - """ 聊天 服务器 角色 条数 页数""" + """ 发言 服务器 角色 条数 页数""" return await self.T2I_image_msg(event, lambda: self.jx3api.juesheliaotian(server,name,limit,page)) async def tongzhanyy(self, event: AstrMessageEvent, server: str = ""): diff --git a/core/server_binding.py b/core/server_binding.py index 403a1eb..e232432 100644 --- a/core/server_binding.py +++ b/core/server_binding.py @@ -14,7 +14,9 @@ class ServerBindingService: self.sql = sqlite self.seed_path = seed_path self._remote_servers: set[str] = set() + self._standard_servers: set[str] = set() self._known_servers: set[str] = set() + self._standard_lookup: dict[str, str] = {} self._server_lookup: dict[str, str] = {} async def initialize(self): @@ -38,6 +40,17 @@ class ServerBindingService: await self._reload_cache() async def _seed_aliases(self): + records = self._load_seed_aliases() + for server, aliases_json in records: + await self.sql.execute( + """ + INSERT OR IGNORE INTO server_aliases (server, aliases) + VALUES (?, ?) + """, + (server, aliases_json), + ) + + def _load_seed_aliases(self) -> list[tuple[str, str]]: try: records = json.loads(self.seed_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: @@ -46,13 +59,21 @@ class ServerBindingService: if not isinstance(records, list): raise RuntimeError("区服别名种子数据必须是数组") + normalized_records: list[tuple[str, str]] = [] + seen_servers: set[str] = set() for record in records: if not isinstance(record, dict): continue server = self._clean(record.get("server")) raw_aliases = record.get("aliases") or [] - if not server or not isinstance(raw_aliases, list): + server_key = self._key(server) + if ( + not server + or not isinstance(raw_aliases, list) + or server_key in seen_servers + ): continue + seen_servers.add(server_key) aliases = [] seen: set[str] = set() for value in raw_aliases: @@ -61,20 +82,35 @@ class ServerBindingService: if alias and key != self._key(server) and key not in seen: seen.add(key) aliases.append(alias) - await self.sql.execute( - """ - INSERT OR IGNORE INTO server_aliases (server, aliases) - VALUES (?, ?) - """, - (server, json.dumps(aliases, ensure_ascii=False)), + normalized_records.append( + (server, json.dumps(aliases, ensure_ascii=False)) ) + return normalized_records + + async def restore_default_aliases(self) -> int: + """使用随插件分发的 JSON 种子完整重写区服别名表。""" + records = self._load_seed_aliases() + statements: list[tuple[str, tuple[Any, ...]]] = [ + ("DELETE FROM server_aliases", ()), + ] + statements.extend( + ( + "INSERT INTO server_aliases (server, aliases) VALUES (?, ?)", + (server, aliases_json), + ) + for server, aliases_json in records + ) + await self.sql.execute_transaction(statements) + await self._reload_cache() + return len(records) async def _reload_cache(self): bindings = await self.list_bindings() alias_rows = await self.list_aliases() - known = set(self._remote_servers) + standard = set(self._remote_servers) + standard.update(row["server"] for row in alias_rows) + known = set(standard) known.update(row["server"] for row in bindings) - known.update(row["server"] for row in alias_rows) lookup = {self._key(server): server for server in known} for row in alias_rows: @@ -84,7 +120,11 @@ class ServerBindingService: # 新增的官方区服名优先于历史别名,避免目录更新后误解析。 lookup.setdefault(self._key(alias), server) + self._standard_servers = standard self._known_servers = known + self._standard_lookup = { + self._key(server): server for server in standard + } self._server_lookup = lookup async def update_server_catalog(self, servers: Iterable[str]): @@ -98,6 +138,12 @@ class ServerBindingService: def known_servers(self) -> list[str]: return sorted(self._known_servers) + def standard_servers(self) -> list[str]: + return sorted(self._standard_servers) + + def resolve_standard_server(self, value: Any) -> str: + return self._standard_lookup.get(self._key(value), "") + def is_known_server(self, value: Any) -> bool: return self._key(value) in self._server_lookup diff --git a/core/session_control.py b/core/session_control.py new file mode 100644 index 0000000..18e5b2c --- /dev/null +++ b/core/session_control.py @@ -0,0 +1,154 @@ +from typing import Any + +from .sqlite import AsyncSQLiteDB + + +class SessionControlService: + """使用 SQLite 保存并缓存插件的会话访问策略。""" + + MODE_ALL = "all" + MODE_WHITELIST = "whitelist" + MODE_BLACKLIST = "blacklist" + MODES = {MODE_ALL, MODE_WHITELIST, MODE_BLACKLIST} + + def __init__(self, sqlite: AsyncSQLiteDB): + self.sql = sqlite + self._mode = self.MODE_ALL + self._entries: dict[str, dict[str, str]] = {} + + async def initialize(self): + await self.sql.execute( + """ + CREATE TABLE IF NOT EXISTS session_control_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + await self.sql.execute( + """ + CREATE TABLE IF NOT EXISTS session_control_entries ( + session_id TEXT PRIMARY KEY, + list_type TEXT NOT NULL CHECK(list_type IN ('whitelist', 'blacklist')), + remark TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + await self.sql.execute( + """ + INSERT OR IGNORE INTO session_control_settings (key, value) + VALUES ('mode', 'all') + """ + ) + await self._reload() + + async def _reload(self): + setting = await self.sql.fetch_one( + "SELECT value FROM session_control_settings WHERE key='mode'" + ) + mode = str(setting.get("value") or "") if setting else "" + self._mode = mode if mode in self.MODES else self.MODE_ALL + + rows = await self.sql.fetch_all( + """ + SELECT session_id, list_type, remark + FROM session_control_entries + ORDER BY session_id + """ + ) + self._entries = { + str(row["session_id"]): { + "session_id": str(row["session_id"]), + "list_type": str(row["list_type"]), + "remark": str(row.get("remark") or ""), + } + for row in rows + } + + @staticmethod + def _normalize_session_id(session_id: Any) -> str: + value = str(session_id or "").strip() + if not value: + raise ValueError("会话 ID 不能为空") + if len(value) > 512: + raise ValueError("会话 ID 不能超过 512 个字符") + return value + + def is_allowed(self, session_id: Any) -> bool: + """同步检查缓存策略,供高频消息和推送分发入口调用。""" + value = str(session_id or "").strip() + if self._mode == self.MODE_ALL: + return True + + entry = self._entries.get(value) + if self._mode == self.MODE_WHITELIST: + return bool(entry and entry["list_type"] == self.MODE_WHITELIST) + return not (entry and entry["list_type"] == self.MODE_BLACKLIST) + + async def set_mode(self, mode: Any): + value = str(mode or "").strip().lower() + if value not in self.MODES: + raise ValueError("会话控制模式无效") + await self.sql.execute( + """ + INSERT INTO session_control_settings (key, value, updated_at) + VALUES ('mode', ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value=excluded.value, + updated_at=CURRENT_TIMESTAMP + """, + (value,), + ) + self._mode = value + + async def save_entry( + self, + session_id: Any, + list_type: Any, + remark: Any = "", + ): + normalized_session_id = self._normalize_session_id(session_id) + normalized_type = str(list_type or "").strip().lower() + if normalized_type not in {self.MODE_WHITELIST, self.MODE_BLACKLIST}: + raise ValueError("名单类型必须是白名单或黑名单") + normalized_remark = str(remark or "").strip() + if len(normalized_remark) > 200: + raise ValueError("备注不能超过 200 个字符") + + await self.sql.execute( + """ + INSERT INTO session_control_entries ( + session_id, list_type, remark, updated_at + ) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + list_type=excluded.list_type, + remark=excluded.remark, + updated_at=CURRENT_TIMESTAMP + """, + (normalized_session_id, normalized_type, normalized_remark), + ) + self._entries[normalized_session_id] = { + "session_id": normalized_session_id, + "list_type": normalized_type, + "remark": normalized_remark, + } + + async def delete_entry(self, session_id: Any): + normalized_session_id = self._normalize_session_id(session_id) + await self.sql.delete( + "session_control_entries", + "session_id=?", + (normalized_session_id,), + ) + self._entries.pop(normalized_session_id, None) + + async def get_state(self) -> dict[str, Any]: + return { + "mode": self._mode, + "entries": sorted( + (dict(entry) for entry in self._entries.values()), + key=lambda entry: entry["session_id"], + ), + } diff --git a/core/sqlite.py b/core/sqlite.py index 419574e..4cbfccc 100644 --- a/core/sqlite.py +++ b/core/sqlite.py @@ -37,6 +37,20 @@ class AsyncSQLiteDB: async with self.conn.execute(sql, params): await self.conn.commit() + async def execute_transaction( + self, + statements: List[Tuple[str, Tuple[Any, ...]]], + ): + """在同一事务内顺序执行多条参数化 SQL。""" + await self.conn.execute("BEGIN IMMEDIATE") + try: + for sql, params in statements: + await self.conn.execute(sql, params) + await self.conn.commit() + except Exception: + await self.conn.rollback() + raise + async def fetch_one(self, sql: str, params: Tuple = ()) -> Optional[Dict[str, Any]]: async with self.conn.execute(sql, params) as cursor: row = await cursor.fetchone() diff --git a/core/webui.py b/core/webui.py new file mode 100644 index 0000000..37865f0 --- /dev/null +++ b/core/webui.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +from astrbot.api.star import Context +from astrbot.api.web import error_response, json_response, request + +from .event_push import EVENT_NAMES + +if TYPE_CHECKING: + from .event_push import EventPushService + from .jx3api_data import JX3APIService + from .kungfu_alias import KungfuAliasService + from .server_binding import ServerBindingService + from .session_control import SessionControlService + + +class WebUIService: + """注册插件管理页接口,并处理 WebUI 的数据读写。""" + + def __init__( + self, + jx3api: JX3APIService, + event_push: EventPushService, + server_binding: ServerBindingService, + kungfu_alias: KungfuAliasService, + session_control: SessionControlService, + ): + self.jx3api = jx3api + self.event_push = event_push + self.server_binding = server_binding + self.kungfu_alias = kungfu_alias + self.session_control = session_control + + def register(self, context: Context, plugin_name: str): + routes = ( + ("dashboard", self.dashboard, ["GET"], "读取会话管理数据"), + ("bindings/save", self.save_binding, ["POST"], "保存会话区服绑定"), + ("bindings/delete", self.delete_binding, ["POST"], "删除会话区服绑定"), + ("aliases/save", self.save_aliases, ["POST"], "保存区服别名"), + ("aliases/delete", self.delete_aliases, ["POST"], "删除区服别名"), + ("aliases/restore", self.restore_aliases, ["POST"], "恢复默认区服别名"), + ("kungfu/save", self.save_kungfu, ["POST"], "保存心法别名"), + ("kungfu/restore", self.restore_kungfu, ["POST"], "恢复默认心法别名"), + ("servers/refresh", self.refresh_servers, ["POST"], "刷新区服目录"), + ( + "session-control/mode", + self.save_session_control_mode, + ["POST"], + "保存会话控制模式", + ), + ( + "session-control/save", + self.save_session_control_entry, + ["POST"], + "保存会话控制名单", + ), + ( + "session-control/delete", + self.delete_session_control_entry, + ["POST"], + "删除会话控制名单", + ), + ) + for path, handler, methods, description in routes: + context.register_web_api( + f"/{plugin_name}/{path}", + handler, + methods, + description, + ) + + @staticmethod + async def _json_payload() -> dict[str, Any]: + payload = await request.json(default={}) + if not isinstance(payload, dict): + raise ValueError("请求正文必须是 JSON 对象") + return payload + + @staticmethod + def _parse_aliases(raw_aliases: Any) -> list[str]: + if isinstance(raw_aliases, str): + return re.split(r"[,,;;\n]+", raw_aliases) + if isinstance(raw_aliases, list): + return [str(value) for value in raw_aliases] + raise ValueError("别名必须是字符串或数组") + + async def dashboard(self): + bindings = await self.server_binding.list_bindings() + subscriptions = await self.event_push.list_subscription_statuses() + aliases = await self.server_binding.list_aliases() + kungfu = await self.kungfu_alias.list_kungfu() + session_control = await self.session_control.get_state() + return json_response( + { + "bindings": bindings, + "subscriptions": subscriptions, + "aliases": aliases, + "kungfu": kungfu, + "servers": self.server_binding.standard_servers(), + "events": { + str(action): name for action, name in EVENT_NAMES.items() + }, + "session_control": session_control, + } + ) + + async def save_binding(self): + try: + payload = await self._json_payload() + server = self.server_binding.resolve_standard_server( + payload.get("server") + ) + if not server: + raise ValueError("绑定区服必须选择标准区服") + await self.server_binding.set_binding( + str(payload.get("session_id") or ""), + server, + ) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"saved": True}) + + async def delete_binding(self): + try: + payload = await self._json_payload() + session_id = str(payload.get("session_id") or "") + if not session_id.strip(): + raise ValueError("会话 ID 不能为空") + await self.server_binding.delete_binding(session_id) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"deleted": True}) + + async def save_aliases(self): + try: + payload = await self._json_payload() + await self.server_binding.set_aliases( + str(payload.get("server") or ""), + self._parse_aliases(payload.get("aliases", [])), + ) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"saved": True}) + + async def delete_aliases(self): + try: + payload = await self._json_payload() + server = str(payload.get("server") or "") + if not server.strip(): + raise ValueError("标准区服名不能为空") + await self.server_binding.delete_aliases(server) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"deleted": True}) + + async def restore_aliases(self): + try: + restored = await self.server_binding.restore_default_aliases() + except (RuntimeError, ValueError) as exc: + return error_response(str(exc), status_code=500) + return json_response({"restored": restored}) + + async def save_kungfu(self): + try: + payload = await self._json_payload() + await self.kungfu_alias.save_aliases( + payload.get("pzid"), + self._parse_aliases(payload.get("aliases", [])), + ) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"saved": True}) + + async def restore_kungfu(self): + try: + restored = await self.kungfu_alias.restore_defaults() + except (RuntimeError, ValueError) as exc: + return error_response(str(exc), status_code=500) + return json_response({"restored": restored}) + + async def refresh_servers(self): + servers = await self.jx3api.server_list() + if not servers: + return error_response("区服目录刷新失败", status_code=502) + await self.server_binding.update_server_catalog(servers) + return json_response({"servers": self.server_binding.standard_servers()}) + + async def save_session_control_mode(self): + try: + payload = await self._json_payload() + await self.session_control.set_mode(payload.get("mode")) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"saved": True}) + + async def save_session_control_entry(self): + try: + payload = await self._json_payload() + await self.session_control.save_entry( + payload.get("session_id"), + payload.get("list_type"), + payload.get("remark"), + ) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"saved": True}) + + async def delete_session_control_entry(self): + try: + payload = await self._json_payload() + await self.session_control.delete_entry(payload.get("session_id")) + except ValueError as exc: + return error_response(str(exc), status_code=400) + return json_response({"deleted": True}) diff --git a/main.py b/main.py index 14b3306..98f5c74 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ import inspect -import re from pathlib import Path from sys import maxsize from typing import cast @@ -8,15 +7,15 @@ from astrbot.api.event import filter, AstrMessageEvent from astrbot.api.star import Context, Star, register, StarTools from astrbot.api import logger from astrbot.api import AstrBotConfig -from astrbot.api.web import error_response, json_response, request - from .core.sqlite import AsyncSQLiteDB from .core.jx3api_data import JX3APIService from .core.jx3box_data import JX3BOXService -from .core.event_push import EVENT_NAMES, EventPushService +from .core.event_push import EventPushService from .core.bilei_data import BiLeidata from .core.kungfu_alias import KungfuAliasService from .core.server_binding import ServerBindingService +from .core.session_control import SessionControlService +from .core.webui import WebUIService from .core.message import MessageBuilder from .core.fun_basic import load_as_base64 @@ -26,7 +25,7 @@ PLUGIN_NAME = "astrbot_plugin_jx3" @register("astrbot_plugin_jx3", "fxdyz", "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。", - "3.4.2", + "3.4.3", "https://github.com/qsc20001102/astrbot_plugin_jx3" ) class Jx3ApiPlugin(Star): @@ -49,7 +48,7 @@ class Jx3ApiPlugin(Star): # 构造所有类 self.create_all() # 注册插件管理页接口 - self._register_web_apis(context) + self.webui.register(context, PLUGIN_NAME) # 声明指令集 @@ -66,6 +65,7 @@ class Jx3ApiPlugin(Star): await self.init_trade_item_cache_data() await self.kungfu_alias.initialize() await self.server_binding.initialize() + await self.session_control.initialize() # 获取区服目录,用于识别完整参数与区服别名。 await self.server_binding.update_server_catalog( @@ -165,11 +165,20 @@ class Jx3ApiPlugin(Star): self.local_sql_db, self.server_alias_seed_path, ) + self.session_control = SessionControlService(self.local_sql_db) self.event_push = EventPushService( cast(Context, self.context), self.conf, self.local_sql_db, self.server_binding, + self.session_control, + ) + self.webui = WebUIService( + self.jx3api, + self.event_push, + self.server_binding, + self.kungfu_alias, + self.session_control, ) self.jx3cmd = MessageBuilder( self.jx3api, @@ -278,7 +287,7 @@ class Jx3ApiPlugin(Star): "资历排行": self. jx3cmd.zilipaixing, "技能": self. jx3cmd.jineng, "奇穴": self. jx3cmd.qixue, - "聊天": self. jx3cmd.liaotian, + "发言": self. jx3cmd.liaotian, "统战": self. jx3cmd.tongzhanyy, "小药": self. jx3cmd.xiaoyao, "骗子": self. jx3cmd.pianzhi, @@ -493,6 +502,13 @@ class Jx3ApiPlugin(Star): event.stop_event() event.should_call_llm(True) + if not self.session_control.is_allowed(event.unified_msg_origin): + logger.info( + f"会话控制已拦截插件指令:command={cmd}, " + f"session={event.unified_msg_origin}" + ) + return + try: args = await self._prepare_server_args(handler, event, args) ret = await self._call_with_auto_args(handler, event, args) @@ -526,117 +542,3 @@ class Jx3ApiPlugin(Star): """解除当前会话的区服绑定。""" await self.server_binding.delete_binding(event.unified_msg_origin) await event.send(event.plain_result("当前会话已解除区服绑定。")) - - def _register_web_apis(self, context: Context): - routes = ( - ("dashboard", self.page_dashboard, ["GET"], "读取会话管理数据"), - ("bindings/save", self.page_save_binding, ["POST"], "保存会话区服绑定"), - ("bindings/delete", self.page_delete_binding, ["POST"], "删除会话区服绑定"), - ("aliases/save", self.page_save_aliases, ["POST"], "保存区服别名"), - ("aliases/delete", self.page_delete_aliases, ["POST"], "删除区服别名"), - ("kungfu/save", self.page_save_kungfu, ["POST"], "保存心法别名"), - ("servers/refresh", self.page_refresh_servers, ["POST"], "刷新区服目录"), - ) - for path, handler, methods, description in routes: - context.register_web_api( - f"/{PLUGIN_NAME}/{path}", - handler, - methods, - description, - ) - - async def page_dashboard(self): - bindings = await self.server_binding.list_bindings() - subscriptions = await self.event_push.list_subscription_statuses() - aliases = await self.server_binding.list_aliases() - kungfu = await self.kungfu_alias.list_kungfu() - return json_response( - { - "bindings": bindings, - "subscriptions": subscriptions, - "aliases": aliases, - "kungfu": kungfu, - "servers": self.server_binding.known_servers(), - "events": {str(action): name for action, name in EVENT_NAMES.items()}, - } - ) - - async def page_save_binding(self): - payload = await request.json(default={}) - if not isinstance(payload, dict): - return error_response("请求正文必须是 JSON 对象", status_code=400) - try: - await self.server_binding.set_binding( - str(payload.get("session_id") or ""), - str(payload.get("server") or ""), - ) - except ValueError as exc: - return error_response(str(exc), status_code=400) - return json_response({"saved": True}) - - async def page_delete_binding(self): - payload = await request.json(default={}) - session_id = str(payload.get("session_id") or "") if isinstance(payload, dict) else "" - if not session_id.strip(): - return error_response("会话 ID 不能为空", status_code=400) - await self.server_binding.delete_binding(session_id) - return json_response({"deleted": True}) - - async def page_save_aliases(self): - payload = await request.json(default={}) - if not isinstance(payload, dict): - return error_response("请求正文必须是 JSON 对象", status_code=400) - raw_aliases = payload.get("aliases", []) - if isinstance(raw_aliases, str): - aliases = re.split(r"[,,;;\n]+", raw_aliases) - elif isinstance(raw_aliases, list): - aliases = [str(value) for value in raw_aliases] - else: - return error_response("别名必须是字符串或数组", status_code=400) - try: - await self.server_binding.set_aliases( - str(payload.get("server") or ""), - aliases, - ) - except ValueError as exc: - return error_response(str(exc), status_code=400) - return json_response({"saved": True}) - - async def page_delete_aliases(self): - payload = await request.json(default={}) - server = str(payload.get("server") or "") if isinstance(payload, dict) else "" - if not server.strip(): - return error_response("标准区服名不能为空", status_code=400) - await self.server_binding.delete_aliases(server) - return json_response({"deleted": True}) - - async def page_save_kungfu(self): - payload = await request.json(default={}) - if not isinstance(payload, dict): - return error_response("请求正文必须是 JSON 对象", status_code=400) - raw_aliases = payload.get("aliases", []) - if isinstance(raw_aliases, str): - aliases = re.split(r"[,,;;\n]+", raw_aliases) - elif isinstance(raw_aliases, list): - aliases = [str(value) for value in raw_aliases] - else: - return error_response("别名必须是字符串或数组", status_code=400) - try: - await self.kungfu_alias.save_aliases( - payload.get("pzid"), - aliases, - ) - except ValueError as exc: - return error_response(str(exc), status_code=400) - return json_response({"saved": True}) - - async def page_refresh_servers(self): - servers = await self.jx3api.server_list() - if not servers: - return error_response("区服目录刷新失败", status_code=502) - await self.server_binding.update_server_catalog(servers) - return json_response({"servers": self.server_binding.known_servers()}) - - - - diff --git a/metadata.yaml b/metadata.yaml index c7d1080..612b11c 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,7 @@ name: astrbot_plugin_jx3 display_name: 剑网三游戏数据查询工具 desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。 -version: 3.4.2 +version: 3.4.3 author: 飞翔大野猪 repo: https://github.com/qsc20001102/astrbot_plugin_jx3 astrbot_version: ">=4.24.1" diff --git a/pages/server-management/app.js b/pages/server-management/app.js index 271e159..bef6740 100644 --- a/pages/server-management/app.js +++ b/pages/server-management/app.js @@ -1,6 +1,15 @@ const bridge = window.AstrBotPluginPage; -const state = { bindings: [], subscriptions: [], aliases: [], kungfu: [], servers: [], events: {} }; -const editing = { aliasServer: null, kungfuPzid: null }; +const state = { + bindings: [], + subscriptions: [], + aliases: [], + kungfu: [], + servers: [], + events: {}, + session_control: { mode: "all", entries: [] }, +}; +const editing = { bindingSession: null, controlSession: null, aliasServer: null, kungfuPzid: null }; +const restoreConfirmationTimers = new WeakMap(); let toastTimer; const byId = (id) => document.getElementById(id); @@ -69,24 +78,226 @@ function inlineAliasEditor(aliases, label, onSave, onCancel) { return { input, controls: [saveButton, cancelButton] }; } +function createServerSelect(selectedServer = "", label = "绑定区服") { + const select = document.createElement("select"); + select.className = "inline-editor"; + select.required = true; + select.setAttribute("aria-label", label); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "请选择标准区服"; + placeholder.disabled = true; + placeholder.defaultSelected = true; + select.append(placeholder); + + state.servers.forEach((server) => { + const option = document.createElement("option"); + option.value = server; + option.textContent = server; + select.append(option); + }); + select.value = state.servers.includes(selectedServer) ? selectedServer : ""; + return select; +} + +function inlineServerEditor(item, onSave, onCancel) { + const select = createServerSelect(item.server, `${item.session_id}的绑定区服`); + const saveButton = button("保存", "", async () => { + if (!select.reportValidity()) return; + select.disabled = true; + saveButton.disabled = true; + cancelButton.disabled = true; + const saved = await onSave(select.value); + if (!saved) { + select.disabled = false; + saveButton.disabled = false; + cancelButton.disabled = false; + select.focus(); + } + }); + const cancelButton = button("取消", "", onCancel); + select.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + saveButton.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelButton.click(); + } + }); + queueMicrotask(() => select.focus()); + return { select, controls: [saveButton, cancelButton] }; +} + function bindingMap() { return new Map(state.bindings.map((item) => [item.session_id, item.server])); } -function renderSummary() { - byId("binding-count").textContent = String(state.bindings.length); - byId("subscription-count").textContent = String(state.subscriptions.filter((item) => item.enabled).length); - byId("alias-count").textContent = String(state.aliases.reduce((total, item) => total + item.aliases.length, 0)); - byId("kungfu-count").textContent = String(state.kungfu.length); -} - function renderServerOptions() { - const list = byId("server-options"); - list.replaceChildren(...state.servers.map((server) => { + const select = byId("binding-server"); + const currentValue = select.value; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "请选择标准区服"; + placeholder.disabled = true; + placeholder.defaultSelected = true; + select.replaceChildren(placeholder, ...state.servers.map((server) => { const option = document.createElement("option"); option.value = server; + option.textContent = server; return option; })); + select.value = state.servers.includes(currentValue) ? currentValue : ""; +} + +function renderSessionOptions() { + const sessionIds = new Set([ + ...state.bindings.map((item) => item.session_id), + ...state.subscriptions.map((item) => item.session_id), + ...state.session_control.entries.map((item) => item.session_id), + ]); + byId("session-options").replaceChildren(...[...sessionIds].sort().map((sessionId) => { + const option = document.createElement("option"); + option.value = sessionId; + return option; + })); +} + +function controlModeCopy(mode) { + if (mode === "whitelist") { + return "只有白名单中的会话可以使用插件和接收事件推送;白名单为空时不放行任何会话。"; + } + if (mode === "blacklist") { + return "黑名单中的会话会被拦截;黑名单为空时放行全部会话。"; + } + return "所有会话都可以使用插件并接收已订阅的事件推送;下方名单暂不生效。"; +} + +function controlModeLabel(mode) { + if (mode === "whitelist") return "白名单"; + if (mode === "blacklist") return "黑名单"; + return "全部会话"; +} + +function updateModeSelection(selectedMode) { + const activeMode = state.session_control?.mode || "all"; + document.querySelectorAll(".mode-option").forEach((option) => { + const input = option.querySelector('input[name="control_mode"]'); + option.classList.toggle("is-selected", input?.value === selectedMode); + option.classList.toggle("is-active-mode", input?.value === activeMode); + }); + const saveButton = byId("control-mode-save"); + saveButton.textContent = selectedMode === activeMode + ? "当前模式已生效" + : `切换为${controlModeLabel(selectedMode)}`; +} + +function renderSessionControl() { + const control = state.session_control || { mode: "all", entries: [] }; + document.querySelectorAll('input[name="control_mode"]').forEach((input) => { + input.checked = input.value === control.mode; + }); + updateModeSelection(control.mode); + byId("control-mode-label").textContent = controlModeLabel(control.mode); + byId("control-mode-hint").textContent = controlModeCopy(control.mode); + + const body = byId("control-entries-body"); + if (!control.entries.length) { + body.replaceChildren(emptyRow(4, "暂无白名单或黑名单会话")); + return; + } + + body.replaceChildren(...control.entries.map((item) => { + const row = document.createElement("tr"); + const session = document.createElement("td"); + const listType = document.createElement("td"); + const remark = document.createElement("td"); + const actions = document.createElement("td"); + session.dataset.label = "会话 ID"; + listType.dataset.label = "名单类型"; + remark.dataset.label = "备注"; + actions.dataset.label = "操作"; + actions.className = "actions"; + session.textContent = item.session_id; + + if (editing.controlSession === item.session_id) { + row.classList.add("is-editing"); + const typeSelect = document.createElement("select"); + typeSelect.className = "inline-editor inline-editor--compact"; + typeSelect.setAttribute("aria-label", `${item.session_id}的名单类型`); + typeSelect.append( + new Option("白名单", "whitelist"), + new Option("黑名单", "blacklist"), + ); + typeSelect.value = item.list_type; + + const remarkInput = document.createElement("input"); + remarkInput.className = "inline-editor"; + remarkInput.maxLength = 200; + remarkInput.value = item.remark || ""; + remarkInput.placeholder = "备注(可选)"; + remarkInput.setAttribute("aria-label", `${item.session_id}的备注`); + + const saveButton = button("保存", "", async () => { + typeSelect.disabled = true; + remarkInput.disabled = true; + saveButton.disabled = true; + cancelButton.disabled = true; + editing.controlSession = null; + const saved = await mutate( + "session-control/save", + { session_id: item.session_id, list_type: typeSelect.value, remark: remarkInput.value }, + "会话名单已保存", + ); + if (!saved) { + editing.controlSession = item.session_id; + renderSessionControl(); + } + }); + const cancelButton = button("取消", "", () => { + editing.controlSession = null; + renderSessionControl(); + }); + remarkInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + saveButton.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelButton.click(); + } + }); + remark.append(remarkInput); + listType.append(typeSelect); + actions.append(saveButton, cancelButton); + queueMicrotask(() => typeSelect.focus()); + } else { + const badge = document.createElement("span"); + badge.className = `list-badge list-badge--${item.list_type}`; + badge.textContent = item.list_type === "whitelist" ? "白名单" : "黑名单"; + listType.append(badge); + remark.textContent = item.remark || "—"; + actions.append( + button("编辑", "", () => { + editing.controlSession = item.session_id; + renderSessionControl(); + }), + button("删除", "link-button--danger", async (event) => { + const deleteButton = event.currentTarget; + deleteButton.disabled = true; + const deleted = await mutate( + "session-control/delete", + { session_id: item.session_id }, + "会话名单已删除", + ); + if (!deleted) deleteButton.disabled = false; + }), + ); + } + row.append(session, listType, remark, actions); + return row; + })); } function renderBindings() { @@ -104,19 +315,50 @@ function renderBindings() { server.dataset.label = "绑定区服"; actions.dataset.label = "操作"; session.textContent = item.session_id; - server.textContent = item.server; actions.className = "actions"; - actions.append( - button("编辑", "", () => { - byId("binding-session").value = item.session_id; - byId("binding-server").value = item.server; - byId("binding-server").focus(); - }), - button("解除绑定", "link-button--danger", async () => { - if (!window.confirm(`确认解除会话 ${item.session_id} 的区服绑定?`)) return; - await mutate("bindings/delete", { session_id: item.session_id }, "绑定已解除"); - }), - ); + if (editing.bindingSession === item.session_id) { + row.classList.add("is-editing"); + const editor = inlineServerEditor( + item, + async (selectedServer) => { + editing.bindingSession = null; + const saved = await mutate( + "bindings/save", + { session_id: item.session_id, server: selectedServer }, + "绑定信息已保存", + ); + if (!saved) { + editing.bindingSession = item.session_id; + renderBindings(); + } + return saved; + }, + () => { + editing.bindingSession = null; + renderBindings(); + }, + ); + server.append(editor.select); + actions.append(...editor.controls); + } else { + server.textContent = item.server; + actions.append( + button("编辑", "", () => { + editing.bindingSession = item.session_id; + renderBindings(); + }), + button("解除绑定", "link-button--danger", async (event) => { + const control = event.currentTarget; + control.disabled = true; + const deleted = await mutate( + "bindings/delete", + { session_id: item.session_id }, + "绑定已解除", + ); + if (!deleted) control.disabled = false; + }), + ); + } row.append(session, server, actions); return row; })); @@ -283,8 +525,9 @@ function renderKungfu() { } function render() { - renderSummary(); renderServerOptions(); + renderSessionOptions(); + renderSessionControl(); renderBindings(); renderSubscriptions(); renderAliases(); @@ -309,6 +552,50 @@ async function mutate(endpoint, payload, successMessage) { } } +function resetRestoreConfirmation(control) { + const timer = restoreConfirmationTimers.get(control); + if (timer) clearTimeout(timer); + restoreConfirmationTimers.delete(control); + delete control.dataset.confirming; + control.classList.remove("button--danger"); + control.textContent = "恢复默认"; +} + +function confirmRestoreInPage(control, confirmation) { + if (control.dataset.confirming === "true") { + resetRestoreConfirmation(control); + return true; + } + + control.dataset.confirming = "true"; + control.classList.add("button--danger"); + control.textContent = "再次点击确认"; + showToast(confirmation); + restoreConfirmationTimers.set( + control, + setTimeout(() => resetRestoreConfirmation(control), 5000), + ); + return false; +} + +async function restoreDefaults(control, endpoint, confirmation, successMessage, resetEditing) { + if (!confirmRestoreInPage(control, confirmation)) return; + const originalLabel = control.textContent; + control.disabled = true; + control.textContent = "恢复中…"; + resetEditing(); + try { + await bridge.apiPost(endpoint, {}); + await loadData(); + showToast(successMessage); + } catch (error) { + showToast(error?.message || "恢复默认失败", true); + } finally { + control.disabled = false; + control.textContent = originalLabel; + } +} + document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => { document.querySelectorAll(".tab").forEach((item) => { @@ -333,6 +620,48 @@ byId("binding-form").addEventListener("submit", async (event) => { if (saved) event.currentTarget.reset(); }); +document.querySelectorAll('input[name="control_mode"]').forEach((input) => { + input.addEventListener("change", () => { + updateModeSelection(input.value); + }); +}); + +byId("control-mode-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const selected = new FormData(event.currentTarget).get("control_mode"); + await mutate("session-control/mode", { mode: selected }, "会话控制模式已保存"); +}); + +byId("control-entry-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const saved = await mutate("session-control/save", { + session_id: byId("control-session").value, + list_type: byId("control-list-type").value, + remark: byId("control-remark").value, + }, "会话名单已保存"); + if (saved) event.currentTarget.reset(); +}); + +byId("restore-aliases").addEventListener("click", async (event) => { + await restoreDefaults( + event.currentTarget, + "aliases/restore", + "再次点击按钮,确认使用内置 JSON 覆盖当前全部区服别名", + "区服别名已恢复默认", + () => { editing.aliasServer = null; }, + ); +}); + +byId("restore-kungfu").addEventListener("click", async (event) => { + await restoreDefaults( + event.currentTarget, + "kungfu/restore", + "再次点击按钮,确认使用内置 JSON 覆盖当前全部心法及别名", + "心法别名已恢复默认", + () => { editing.kungfuPzid = null; }, + ); +}); + byId("refresh").addEventListener("click", async (event) => { const control = event.currentTarget; control.disabled = true; diff --git a/pages/server-management/index.html b/pages/server-management/index.html index 953dfa2..033b7a1 100644 --- a/pages/server-management/index.html +++ b/pages/server-management/index.html @@ -12,38 +12,76 @@ 剑网三插件管理 - 管理会话区服绑定、事件订阅状态、区服别名与心法别名。 + 管理事件推送、会话访问范围、区服绑定及别名。 刷新数据 - - - 已绑定会话 - — - - - 事件推送会话 - — - - - 区服别名 - — - - - 心法条目 - — - - - - 会话绑定 - 事件订阅 + 会话控制 + 事件推送 + 区服绑定 区服别名 心法别名 - + + + + + 会话访问模式 + 所有会话都可以使用插件并接收已订阅的事件推送。 + + + 当前生效模式 + 全部会话 + + + + + + 全部会话不限制任何会话,默认模式 + + + + 白名单只允许白名单中的会话 + + + + 黑名单只拦截黑名单中的会话 + + 保存模式 + + + + + + 会话 ID + + + + + 名单类型 + + 白名单 + 黑名单 + + + + 备注(可选) + + + 添加会话 + + + + 会话 ID名单类型备注操作 + + + + + + 会话 ID @@ -51,9 +89,11 @@ 绑定区服 - + + 请选择标准区服 + - 保存绑定 + 添加绑定 @@ -76,8 +116,9 @@ - - 区服别名标准区服名称为只读;点击对应行的“编辑”可修改别名。 + + 区服别名标准区服名称为只读;点击对应行的“编辑”可修改别名,恢复默认会使用内置 JSON 覆盖当前配置。 + 恢复默认 @@ -88,8 +129,9 @@ - - 心法别名标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名。 + + 心法别名标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名,恢复默认会使用内置 JSON 覆盖当前配置。 + 恢复默认 @@ -100,7 +142,6 @@ -
管理会话区服绑定、事件订阅状态、区服别名与心法别名。
管理事件推送、会话访问范围、区服绑定及别名。
所有会话都可以使用插件并接收已订阅的事件推送。
标准区服名称为只读;点击对应行的“编辑”可修改别名。
标准区服名称为只读;点击对应行的“编辑”可修改别名,恢复默认会使用内置 JSON 覆盖当前配置。
标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名。
标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名,恢复默认会使用内置 JSON 覆盖当前配置。