This commit is contained in:
qsc
2026-09-05 02:36:59 +08:00
parent 9c9808adf7
commit 694f6a9604
17 changed files with 1300 additions and 466 deletions
+14
View File
@@ -1,5 +1,19 @@
## 更新记录 ## 更新记录
### version: 3.4.32026-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.22026-09-04): ### version: 3.4.22026-09-04):
图片模板整体调整为浅色背景、深色高对比文本和更大的字号,缩小内容与金色边框之间的留白并扩大主体区域;同步检查并调整多个独立页面的字号、间距及表格布局,提高长图阅读体验。 图片模板整体调整为浅色背景、深色高对比文本和更大的字号,缩小内容与金色边框之间的留白并扩大主体区域;同步检查并调整多个独立页面的字号、间距及表格布局,提高长图阅读体验。
+24 -15
View File
@@ -13,7 +13,7 @@
## 功能特点 ## 功能特点
- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、副本、家园、社区等场景。 - 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、百战、家园、社区等场景。
- 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。 - 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。
- 支持 `宏``资历` 以及帮会、阵营、其他排行榜的 10 秒两轮交互,超时默认选择第一项。 - 支持 `宏``资历` 以及帮会、阵营、其他排行榜的 10 秒两轮交互,超时默认选择第一项。
- 查询图片统一使用浅色高对比主题,并可在插件配置页调整渲染清晰度、输出格式和 JPEG 质量。 - 查询图片统一使用浅色高对比主题,并可在插件配置页调整渲染清晰度、输出格式和 JPEG 质量。
@@ -231,7 +231,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| 指令 | 说明与输出 | 凭据 | | 指令 | 说明与输出 | 凭据 |
| --- | --- | --- | | --- | --- | --- |
| `聊天 服务器 角色 [条数] [页数]` | 角色聊天记录,默认 20 条、第 1 页;图片 | Token | | `发言 服务器 角色 [条数] [页数]` | 角色发言记录,默认 20 条、第 1 页;图片 | Token |
| `统战 [服务器]` | 统战频道统计;文本 | 无 | | `统战 [服务器]` | 统战频道统计;文本 | 无 |
| `小药 [心法]` | 小吃小药推荐;图片 | 无 | | `小药 [心法]` | 小吃小药推荐;图片 | 无 |
| `骗子 UID [服务器]` | 查询欺诈记录;文本 | Token | | `骗子 UID [服务器]` | 查询欺诈记录;文本 | Token |
@@ -274,7 +274,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `避雷修改 ID 名称 备注` | 按 ID 更新记录,同时覆盖修改时间和修改人 | | `避雷修改 ID 名称 备注` | 按 ID 更新记录,同时覆盖修改时间和修改人 |
| `避雷删除 ID` | 按 ID 删除记录 | | `避雷删除 ID` | 按 ID 删除记录 |
避雷功能没有内置权限校验。只要能够触发插件指令,当前实现就允许新增、修改和删除记录;公开群聊部署时应在 AstrBot 或消息平台层配置访问控制 避雷功能没有会话内的用户角色校验。在“会话控制”放行的会话中,只要能够触发插件指令,当前实现就允许新增、修改和删除记录;如需限制到指定管理员,仍应在 AstrBot 或消息平台层配置权限
### 实时事件推送 ### 实时事件推送
@@ -291,7 +291,9 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
### WebUI 插件管理 ### 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 flowchart LR
A["AstrBot 消息事件"] --> B["前缀检查与空白切分"] A["AstrBot 消息事件"] --> B["前缀检查与空白切分"]
B --> C["command_map 查找处理器"] B --> C["command_map 查找处理器"]
C --> D["MessageBuilder 参数适配"] C --> K{"会话控制是否放行"}
K -->|是| D["MessageBuilder 参数适配"]
K -->|否| X["停止处理"]
D --> E1["JX3APIService"] D --> E1["JX3APIService"]
D --> E2["JX3BOXService"] D --> E2["JX3BOXService"]
D --> E3["BiLeidata / EventPushService / KungfuAliasService"] D --> E3["BiLeidata / EventPushService / KungfuAliasService"]
@@ -322,13 +326,15 @@ flowchart LR
1. 读取前缀、Token、Ticket 和 WebSocket 配置。 1. 读取前缀、Token、Ticket 和 WebSocket 配置。
2. 计算插件目录、AstrBot 数据目录、本地 SQLite 和模板目录路径。 2. 计算插件目录、AstrBot 数据目录、本地 SQLite 和模板目录路径。
3.`templates/img``templates/img/sand``templates/sect``templates/serendipity` 中的图片读取为 base64 Data URL。 3.`templates/img``templates/img/sand``templates/sect``templates/serendipity` 中的图片读取为 base64 Data URL。
4. 创建本地数据库、两个数据服务、心法别名服务、避雷服务、事件推送服务和消息构建器。 4. 创建本地数据库、两个数据服务、心法别名服务、会话控制服务、避雷服务、事件推送服务、WebUI 服务和消息构建器。
异步初始化阶段会连接数据库并创建以下本地表: 异步初始化阶段会连接数据库并创建以下本地表:
- `bilei`:避雷记录。 - `bilei`:避雷记录。
- `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。 - `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。
- `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。 - `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。
- `session_control_settings`:保存当前会话控制模式,首次初始化默认为 `all`
- `session_control_entries`:保存白名单或黑名单会话 ID 及备注。
- `server_aliases`:保存标准区服名及其 JSON 别名列表。 - `server_aliases`:保存标准区服名及其 JSON 别名列表。
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。 - `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
- `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。 - `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/kungfu.json` | 随插件分发,只在初始化时读取 | 32 条默认心法、别名和 JX3BOX 配装 ID |
| `data/server_aliases.json` | 随插件分发,只在初始化时读取 | 默认标准区服及其别名 | | `data/server_aliases.json` | 随插件分发,只在初始化时读取 | 默认标准区服及其别名 |
@@ -441,7 +447,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。当前支持官方 `SocketEventMap` 中的全部 39 种事件,包含奇遇、马驹、扶摇、烟花、的卢、玄晶、阵营、宣战、据点、攻防拍卖与免费资讯事件。 `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` 轮询状态表会被删除。 表中使用 `action_1001` 这类列名保存各事件开关,并在新增事件编号时自动为旧数据库补列。升级时旧的 `tuishong` 轮询状态表会被删除。
@@ -465,6 +471,8 @@ astrbot_plugin_jx3/
│ ├── message.py # 文本、图片、消息链和两轮会话构建 │ ├── message.py # 文本、图片、消息链和两轮会话构建
│ ├── request.py # aiohttp 请求封装 │ ├── request.py # aiohttp 请求封装
│ ├── event_push.py # WebSocket 事件通道与会话订阅 │ ├── event_push.py # WebSocket 事件通道与会话订阅
│ ├── session_control.py # 全部会话、白名单与黑名单访问策略
│ ├── webui.py # 插件管理页路由、数据聚合与请求处理
│ ├── server_binding.py # 会话区服绑定、别名与区服目录 │ ├── server_binding.py # 会话区服绑定、别名与区服目录
│ ├── kungfu_alias.py # 本地心法目录初始化、校验与 WebUI 写入 │ ├── kungfu_alias.py # 本地心法目录初始化、校验与 WebUI 写入
│ ├── bilei_data.py # 避雷数据增删改查 │ ├── bilei_data.py # 避雷数据增删改查
@@ -512,16 +520,17 @@ git diff --check
## 当前版本状态 ## 当前版本状态
以下内容是对 v3.4.2 当前源码的静态核对结果,部署和二次开发前应注意: 以下内容是对 v3.4.3 当前源码的静态核对结果,部署和二次开发前应注意:
1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。 1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。
2. `帮会排行``阵营排行``其他排行` 使用固定的“数字序号 → 榜单名称”首轮菜单,次轮查询统一截取接口返回的前 50 条;取得有效选择后会先停止超时计时器,避免渲染期间重复输出。 2. `帮会排行``阵营排行``其他排行` 使用固定的“数字序号 → 榜单名称”首轮菜单,次轮查询统一截取接口返回的前 50 条;取得有效选择后会先停止超时计时器,避免渲染期间重复输出。
3. 区服别名 WebUI 展示完整标准区服目录并只允许行内编辑别名心法别名 WebUI 隐藏配装 ID,后端保存接口也只更新已有心法的别名。 3. 区服绑定 WebUI 的新增及行内编辑均只接受标准区服,聊天指令仍可使用区服别名;区服别名页展示完整标准区服目录并只允许行内编辑别名心法别名隐藏配装 ID,后端保存接口也只更新已有心法的别名。“恢复默认”使用页面内二次点击确认,确认后会用随包 JSON 完整覆盖对应数据库表,当前自定义别名无法从页面撤销恢复。
4. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况 4. 会话控制默认放行全部会话;切换白名单后只有白名单记录可执行插件指令及接收推送,切换黑名单后仅拦截黑名单记录。名单记录会保留在数据库中,切换模式不会删除已有配置
5. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server` 5. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况
6. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces``seniority``score/totalScore` 渲染,JX3BOX 不再参与资历查询 6. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`
7. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置 7. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces``seniority``score/totalScore` 渲染,JX3BOX 不再参与资历查询
8. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出 8. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置
9. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
## 注意事项 ## 注意事项
+4
View File
@@ -15,6 +15,7 @@ from astrbot.api.star import Context
from .sqlite import AsyncSQLiteDB from .sqlite import AsyncSQLiteDB
from .server_binding import ServerBindingService from .server_binding import ServerBindingService
from .session_control import SessionControlService
DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" DEFAULT_WSS_URL = "wss://socket.nicemoe.cn"
@@ -294,11 +295,13 @@ class EventPushService:
config: AstrBotConfig, config: AstrBotConfig,
sqlite: AsyncSQLiteDB, sqlite: AsyncSQLiteDB,
server_binding: ServerBindingService, server_binding: ServerBindingService,
session_control: SessionControlService,
): ):
self.context = context self.context = context
self.config = config self.config = config
self.sql = sqlite self.sql = sqlite
self.server_binding = server_binding self.server_binding = server_binding
self.session_control = session_control
self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip() self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip()
self.token = str(config.get("jx3api_wss_token", "") or "").strip() self.token = str(config.get("jx3api_wss_token", "") or "").strip()
self._runner: Optional[asyncio.Task] = None self._runner: Optional[asyncio.Task] = None
@@ -520,6 +523,7 @@ class EventPushService:
return [ return [
(str(row["session_id"]), str(row.get("server") or "").strip()) (str(row["session_id"]), str(row.get("server") or "").strip())
for row in rows for row in rows
if self.session_control.is_allowed(row["session_id"])
] ]
async def list_subscription_statuses(self) -> list[dict[str, Any]]: async def list_subscription_statuses(self) -> list[dict[str, Any]]:
+34 -3
View File
@@ -31,6 +31,18 @@ class KungfuAliasService:
await self._seed_defaults() await self._seed_defaults()
async def _seed_defaults(self): 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: try:
records = json.loads(self.seed_path.read_text(encoding="utf-8")) records = json.loads(self.seed_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
@@ -39,21 +51,40 @@ class KungfuAliasService:
if not isinstance(records, list): if not isinstance(records, list):
raise RuntimeError("心法种子数据必须是数组") raise RuntimeError("心法种子数据必须是数组")
normalized_records: list[tuple[Any, ...]] = []
seen_pzids: set[int] = set()
for record in records: for record in records:
if not isinstance(record, dict): if not isinstance(record, dict):
continue continue
pzid = self._parse_pzid(record.get("pzid")) pzid = self._parse_pzid(record.get("pzid"))
if pzid in seen_pzids:
continue
seen_pzids.add(pzid)
name = self._clean(record.get("name")) name = self._clean(record.get("name"))
aliases = self._normalize_aliases(name, record.get("aliases") or []) aliases = self._normalize_aliases(name, record.get("aliases") or [])
values = [*aliases, *([None] * (self.MAX_ALIASES - len(aliases)))] 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) (pzid, name, name1, name2, name3, name4, name5)
VALUES (?, ?, ?, ?, ?, ?, ?) 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]]: async def list_kungfu(self) -> list[dict[str, Any]]:
rows = await self.sql.fetch_all( rows = await self.sql.fetch_all(
+1 -1
View File
@@ -486,7 +486,7 @@ class MessageBuilder:
return await self.T2I_image_msg(event, lambda: self.jx3api.qixue(name,0)) 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): 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)) return await self.T2I_image_msg(event, lambda: self.jx3api.juesheliaotian(server,name,limit,page))
async def tongzhanyy(self, event: AstrMessageEvent, server: str = ""): async def tongzhanyy(self, event: AstrMessageEvent, server: str = ""):
+55 -9
View File
@@ -14,7 +14,9 @@ class ServerBindingService:
self.sql = sqlite self.sql = sqlite
self.seed_path = seed_path self.seed_path = seed_path
self._remote_servers: set[str] = set() self._remote_servers: set[str] = set()
self._standard_servers: set[str] = set()
self._known_servers: set[str] = set() self._known_servers: set[str] = set()
self._standard_lookup: dict[str, str] = {}
self._server_lookup: dict[str, str] = {} self._server_lookup: dict[str, str] = {}
async def initialize(self): async def initialize(self):
@@ -38,6 +40,17 @@ class ServerBindingService:
await self._reload_cache() await self._reload_cache()
async def _seed_aliases(self): 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: try:
records = json.loads(self.seed_path.read_text(encoding="utf-8")) records = json.loads(self.seed_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
@@ -46,13 +59,21 @@ class ServerBindingService:
if not isinstance(records, list): if not isinstance(records, list):
raise RuntimeError("区服别名种子数据必须是数组") raise RuntimeError("区服别名种子数据必须是数组")
normalized_records: list[tuple[str, str]] = []
seen_servers: set[str] = set()
for record in records: for record in records:
if not isinstance(record, dict): if not isinstance(record, dict):
continue continue
server = self._clean(record.get("server")) server = self._clean(record.get("server"))
raw_aliases = record.get("aliases") or [] 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 continue
seen_servers.add(server_key)
aliases = [] aliases = []
seen: set[str] = set() seen: set[str] = set()
for value in raw_aliases: for value in raw_aliases:
@@ -61,20 +82,35 @@ class ServerBindingService:
if alias and key != self._key(server) and key not in seen: if alias and key != self._key(server) and key not in seen:
seen.add(key) seen.add(key)
aliases.append(alias) aliases.append(alias)
await self.sql.execute( normalized_records.append(
""" (server, json.dumps(aliases, ensure_ascii=False))
INSERT OR IGNORE INTO server_aliases (server, aliases)
VALUES (?, ?)
""",
(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): async def _reload_cache(self):
bindings = await self.list_bindings() bindings = await self.list_bindings()
alias_rows = await self.list_aliases() 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 bindings)
known.update(row["server"] for row in alias_rows)
lookup = {self._key(server): server for server in known} lookup = {self._key(server): server for server in known}
for row in alias_rows: for row in alias_rows:
@@ -84,7 +120,11 @@ class ServerBindingService:
# 新增的官方区服名优先于历史别名,避免目录更新后误解析。 # 新增的官方区服名优先于历史别名,避免目录更新后误解析。
lookup.setdefault(self._key(alias), server) lookup.setdefault(self._key(alias), server)
self._standard_servers = standard
self._known_servers = known self._known_servers = known
self._standard_lookup = {
self._key(server): server for server in standard
}
self._server_lookup = lookup self._server_lookup = lookup
async def update_server_catalog(self, servers: Iterable[str]): async def update_server_catalog(self, servers: Iterable[str]):
@@ -98,6 +138,12 @@ class ServerBindingService:
def known_servers(self) -> list[str]: def known_servers(self) -> list[str]:
return sorted(self._known_servers) 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: def is_known_server(self, value: Any) -> bool:
return self._key(value) in self._server_lookup return self._key(value) in self._server_lookup
+154
View File
@@ -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"],
),
}
+14
View File
@@ -37,6 +37,20 @@ class AsyncSQLiteDB:
async with self.conn.execute(sql, params): async with self.conn.execute(sql, params):
await self.conn.commit() 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 def fetch_one(self, sql: str, params: Tuple = ()) -> Optional[Dict[str, Any]]:
async with self.conn.execute(sql, params) as cursor: async with self.conn.execute(sql, params) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
+216
View File
@@ -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})
+23 -121
View File
@@ -1,5 +1,4 @@
import inspect import inspect
import re
from pathlib import Path from pathlib import Path
from sys import maxsize from sys import maxsize
from typing import cast 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.star import Context, Star, register, StarTools
from astrbot.api import logger from astrbot.api import logger
from astrbot.api import AstrBotConfig from astrbot.api import AstrBotConfig
from astrbot.api.web import error_response, json_response, request
from .core.sqlite import AsyncSQLiteDB from .core.sqlite import AsyncSQLiteDB
from .core.jx3api_data import JX3APIService from .core.jx3api_data import JX3APIService
from .core.jx3box_data import JX3BOXService 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.bilei_data import BiLeidata
from .core.kungfu_alias import KungfuAliasService from .core.kungfu_alias import KungfuAliasService
from .core.server_binding import ServerBindingService from .core.server_binding import ServerBindingService
from .core.session_control import SessionControlService
from .core.webui import WebUIService
from .core.message import MessageBuilder from .core.message import MessageBuilder
from .core.fun_basic import load_as_base64 from .core.fun_basic import load_as_base64
@@ -26,7 +25,7 @@ PLUGIN_NAME = "astrbot_plugin_jx3"
@register("astrbot_plugin_jx3", @register("astrbot_plugin_jx3",
"fxdyz", "fxdyz",
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。", "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
"3.4.2", "3.4.3",
"https://github.com/qsc20001102/astrbot_plugin_jx3" "https://github.com/qsc20001102/astrbot_plugin_jx3"
) )
class Jx3ApiPlugin(Star): class Jx3ApiPlugin(Star):
@@ -49,7 +48,7 @@ class Jx3ApiPlugin(Star):
# 构造所有类 # 构造所有类
self.create_all() 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.init_trade_item_cache_data()
await self.kungfu_alias.initialize() await self.kungfu_alias.initialize()
await self.server_binding.initialize() await self.server_binding.initialize()
await self.session_control.initialize()
# 获取区服目录,用于识别完整参数与区服别名。 # 获取区服目录,用于识别完整参数与区服别名。
await self.server_binding.update_server_catalog( await self.server_binding.update_server_catalog(
@@ -165,11 +165,20 @@ class Jx3ApiPlugin(Star):
self.local_sql_db, self.local_sql_db,
self.server_alias_seed_path, self.server_alias_seed_path,
) )
self.session_control = SessionControlService(self.local_sql_db)
self.event_push = EventPushService( self.event_push = EventPushService(
cast(Context, self.context), cast(Context, self.context),
self.conf, self.conf,
self.local_sql_db, self.local_sql_db,
self.server_binding, 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.jx3cmd = MessageBuilder(
self.jx3api, self.jx3api,
@@ -278,7 +287,7 @@ class Jx3ApiPlugin(Star):
"资历排行": self. jx3cmd.zilipaixing, "资历排行": self. jx3cmd.zilipaixing,
"技能": self. jx3cmd.jineng, "技能": self. jx3cmd.jineng,
"奇穴": self. jx3cmd.qixue, "奇穴": self. jx3cmd.qixue,
"聊天": self. jx3cmd.liaotian, "发言": self. jx3cmd.liaotian,
"统战": self. jx3cmd.tongzhanyy, "统战": self. jx3cmd.tongzhanyy,
"小药": self. jx3cmd.xiaoyao, "小药": self. jx3cmd.xiaoyao,
"骗子": self. jx3cmd.pianzhi, "骗子": self. jx3cmd.pianzhi,
@@ -493,6 +502,13 @@ class Jx3ApiPlugin(Star):
event.stop_event() event.stop_event()
event.should_call_llm(True) 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: try:
args = await self._prepare_server_args(handler, event, args) args = await self._prepare_server_args(handler, event, args)
ret = await self._call_with_auto_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 self.server_binding.delete_binding(event.unified_msg_origin)
await event.send(event.plain_result("当前会话已解除区服绑定。")) 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()})
+1 -1
View File
@@ -1,7 +1,7 @@
name: astrbot_plugin_jx3 name: astrbot_plugin_jx3
display_name: 剑网三游戏数据查询工具 display_name: 剑网三游戏数据查询工具
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。 desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
version: 3.4.2 version: 3.4.3
author: 飞翔大野猪 author: 飞翔大野猪
repo: https://github.com/qsc20001102/astrbot_plugin_jx3 repo: https://github.com/qsc20001102/astrbot_plugin_jx3
astrbot_version: ">=4.24.1" astrbot_version: ">=4.24.1"
+353 -24
View File
@@ -1,6 +1,15 @@
const bridge = window.AstrBotPluginPage; const bridge = window.AstrBotPluginPage;
const state = { bindings: [], subscriptions: [], aliases: [], kungfu: [], servers: [], events: {} }; const state = {
const editing = { aliasServer: null, kungfuPzid: null }; 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; let toastTimer;
const byId = (id) => document.getElementById(id); const byId = (id) => document.getElementById(id);
@@ -69,24 +78,226 @@ function inlineAliasEditor(aliases, label, onSave, onCancel) {
return { input, controls: [saveButton, cancelButton] }; 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() { function bindingMap() {
return new Map(state.bindings.map((item) => [item.session_id, item.server])); 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() { function renderServerOptions() {
const list = byId("server-options"); const select = byId("binding-server");
list.replaceChildren(...state.servers.map((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"); const option = document.createElement("option");
option.value = server; option.value = server;
option.textContent = server;
return option; 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() { function renderBindings() {
@@ -104,19 +315,50 @@ function renderBindings() {
server.dataset.label = "绑定区服"; server.dataset.label = "绑定区服";
actions.dataset.label = "操作"; actions.dataset.label = "操作";
session.textContent = item.session_id; session.textContent = item.session_id;
server.textContent = item.server;
actions.className = "actions"; actions.className = "actions";
actions.append( if (editing.bindingSession === item.session_id) {
button("编辑", "", () => { row.classList.add("is-editing");
byId("binding-session").value = item.session_id; const editor = inlineServerEditor(
byId("binding-server").value = item.server; item,
byId("binding-server").focus(); async (selectedServer) => {
}), editing.bindingSession = null;
button("解除绑定", "link-button--danger", async () => { const saved = await mutate(
if (!window.confirm(`确认解除会话 ${item.session_id} 的区服绑定?`)) return; "bindings/save",
await mutate("bindings/delete", { session_id: item.session_id }, "绑定已解除"); { 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); row.append(session, server, actions);
return row; return row;
})); }));
@@ -283,8 +525,9 @@ function renderKungfu() {
} }
function render() { function render() {
renderSummary();
renderServerOptions(); renderServerOptions();
renderSessionOptions();
renderSessionControl();
renderBindings(); renderBindings();
renderSubscriptions(); renderSubscriptions();
renderAliases(); 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) => { document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => { tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((item) => { document.querySelectorAll(".tab").forEach((item) => {
@@ -333,6 +620,48 @@ byId("binding-form").addEventListener("submit", async (event) => {
if (saved) event.currentTarget.reset(); 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) => { byId("refresh").addEventListener("click", async (event) => {
const control = event.currentTarget; const control = event.currentTarget;
control.disabled = true; control.disabled = true;
+71 -30
View File
@@ -12,38 +12,76 @@
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>剑网三插件管理</h1> <h1>剑网三插件管理</h1>
<p>管理会话区服绑定、事件订阅状态、区服别名与心法别名。</p> <p>管理事件推送、会话访问范围、区服绑定及别名。</p>
</div> </div>
<button class="button button--secondary" id="refresh" type="button">刷新数据</button> <button class="button button--secondary" id="refresh" type="button">刷新数据</button>
</header> </header>
<section class="summary" aria-label="数据概览">
<div class="summary__item">
<span>已绑定会话</span>
<strong id="binding-count"></strong>
</div>
<div class="summary__item">
<span>事件推送会话</span>
<strong id="subscription-count"></strong>
</div>
<div class="summary__item">
<span>区服别名</span>
<strong id="alias-count"></strong>
</div>
<div class="summary__item">
<span>心法条目</span>
<strong id="kungfu-count"></strong>
</div>
</section>
<nav class="tabs" aria-label="管理分类" role="tablist"> <nav class="tabs" aria-label="管理分类" role="tablist">
<button class="tab is-active" type="button" role="tab" aria-selected="true" data-tab="bindings">会话绑定</button> <button class="tab is-active" type="button" role="tab" aria-selected="true" data-tab="session-control">会话控制</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="subscriptions">事件订阅</button> <button class="tab" type="button" role="tab" aria-selected="false" data-tab="subscriptions">事件推送</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="bindings">区服绑定</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="aliases">区服别名</button> <button class="tab" type="button" role="tab" aria-selected="false" data-tab="aliases">区服别名</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="kungfu">心法别名</button> <button class="tab" type="button" role="tab" aria-selected="false" data-tab="kungfu">心法别名</button>
</nav> </nav>
<section class="panel is-active" id="bindings-panel" role="tabpanel"> <section class="panel is-active" id="session-control-panel" role="tabpanel">
<form class="control-mode" id="control-mode-form">
<div class="section-intro control-mode__intro">
<div>
<h2>会话访问模式</h2>
<p id="control-mode-hint">所有会话都可以使用插件并接收已订阅的事件推送。</p>
</div>
<div class="current-mode" aria-live="polite">
<span>当前生效模式</span>
<strong id="control-mode-label">全部会话</strong>
</div>
</div>
<div class="mode-options" role="radiogroup" aria-label="会话访问模式">
<label class="mode-option">
<input type="radio" name="control_mode" value="all" />
<span><strong>全部会话</strong><small>不限制任何会话,默认模式</small></span>
</label>
<label class="mode-option">
<input type="radio" name="control_mode" value="whitelist" />
<span><strong>白名单</strong><small>只允许白名单中的会话</small></span>
</label>
<label class="mode-option">
<input type="radio" name="control_mode" value="blacklist" />
<span><strong>黑名单</strong><small>只拦截黑名单中的会话</small></span>
</label>
<button class="button button--primary" id="control-mode-save" type="submit">保存模式</button>
</div>
</form>
<form class="editor editor--control" id="control-entry-form">
<label>
<span>会话 ID</span>
<input id="control-session" name="session_id" list="session-options" maxlength="512" placeholder="可选择已有会话或直接输入" required />
<datalist id="session-options"></datalist>
</label>
<label>
<span>名单类型</span>
<select id="control-list-type" name="list_type" required>
<option value="whitelist">白名单</option>
<option value="blacklist">黑名单</option>
</select>
</label>
<label>
<span>备注(可选)</span>
<input id="control-remark" name="remark" maxlength="200" placeholder="例如:测试会话" />
</label>
<button class="button button--primary" type="submit">添加会话</button>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>会话 ID</th><th>名单类型</th><th>备注</th><th class="actions">操作</th></tr></thead>
<tbody id="control-entries-body"></tbody>
</table>
</div>
</section>
<section class="panel" id="bindings-panel" role="tabpanel" hidden>
<form class="editor" id="binding-form"> <form class="editor" id="binding-form">
<label> <label>
<span>会话 ID</span> <span>会话 ID</span>
@@ -51,9 +89,11 @@
</label> </label>
<label> <label>
<span>绑定区服</span> <span>绑定区服</span>
<input id="binding-server" name="server" maxlength="64" list="server-options" placeholder="选择或输入标准区服名/别名" required /> <select id="binding-server" name="server" required>
<option value="" selected disabled>请选择标准区服</option>
</select>
</label> </label>
<button class="button button--primary" type="submit">保存绑定</button> <button class="button button--primary" type="submit">添加绑定</button>
</form> </form>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -76,8 +116,9 @@
</section> </section>
<section class="panel" id="aliases-panel" role="tabpanel" hidden> <section class="panel" id="aliases-panel" role="tabpanel" hidden>
<div class="section-intro"> <div class="section-intro section-intro--actions">
<div><h2>区服别名</h2><p>标准区服名称为只读;点击对应行的“编辑”可修改别名。</p></div> <div><h2>区服别名</h2><p>标准区服名称为只读;点击对应行的“编辑”可修改别名,恢复默认会使用内置 JSON 覆盖当前配置</p></div>
<button class="button button--secondary" id="restore-aliases" type="button">恢复默认</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -88,8 +129,9 @@
</section> </section>
<section class="panel" id="kungfu-panel" role="tabpanel" hidden> <section class="panel" id="kungfu-panel" role="tabpanel" hidden>
<div class="section-intro"> <div class="section-intro section-intro--actions">
<div><h2>心法别名</h2><p>标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名。</p></div> <div><h2>心法别名</h2><p>标准心法名称为只读;点击对应行的“编辑”可修改最多 5 个别名,恢复默认会使用内置 JSON 覆盖当前配置</p></div>
<button class="button button--secondary" id="restore-kungfu" type="button">恢复默认</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -100,7 +142,6 @@
</section> </section>
</main> </main>
<datalist id="server-options"></datalist>
<div class="toast" id="toast" role="status" aria-live="polite"></div> <div class="toast" id="toast" role="status" aria-live="polite"></div>
<script type="module" src="./app.js"></script> <script type="module" src="./app.js"></script>
</body> </body>
+31 -12
View File
@@ -35,35 +35,48 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; line-height: 1.55; } body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; line-height: 1.55; }
button, input { font: inherit; } button, input, select { font: inherit; }
.shell { width: min(1260px, calc(100% - 40px)); margin: 0 auto; padding: 32px 0 56px; } .shell { width: min(1260px, calc(100% - 40px)); margin: 0 auto; padding: 32px 0 56px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
h1 { margin: 0; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -0.025em; } h1 { margin: 0; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -0.025em; }
.page-header p, .section-intro p { margin: 7px 0 0; color: var(--muted); } .page-header p, .section-intro p { margin: 7px 0 0; color: var(--muted); }
.button { min-height: 42px; padding: 9px 18px; border: 1px solid transparent; border-radius: 9px; cursor: pointer; font-weight: 650; transition: 160ms ease; white-space: nowrap; } .button { min-height: 42px; padding: 9px 18px; border: 1px solid transparent; border-radius: 9px; cursor: pointer; font-weight: 650; transition: 160ms ease; white-space: nowrap; }
.button:focus-visible, .tab:focus-visible, input:focus-visible, .link-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus) 45%, transparent); outline-offset: 2px; } .button:focus-visible, .tab:focus-visible, input:focus-visible, select:focus-visible, .link-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus) 45%, transparent); outline-offset: 2px; }
.button:disabled { cursor: wait; opacity: .65; } .button:disabled { cursor: wait; opacity: .65; }
.button--primary { color: #fff; background: var(--accent); box-shadow: 0 5px 14px color-mix(in srgb, var(--accent) 20%, transparent); } .button--primary { color: #fff; background: var(--accent); box-shadow: 0 5px 14px color-mix(in srgb, var(--accent) 20%, transparent); }
.button--primary:hover { background: var(--accent-hover); transform: translateY(-1px); } .button--primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
.button--secondary { color: var(--text); background: var(--surface); border-color: var(--border); } .button--secondary { color: var(--text); background: var(--surface); border-color: var(--border); }
.button--secondary:hover { border-color: color-mix(in srgb, var(--text) 24%, var(--border)); } .button--secondary:hover { border-color: color-mix(in srgb, var(--text) 24%, var(--border)); }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); } .button--danger { color: #fff; background: var(--danger); border-color: var(--danger); }
.summary__item { display: flex; min-height: 112px; flex-direction: column; justify-content: center; padding: 22px 30px; } .button--danger:hover { background: color-mix(in srgb, var(--danger) 88%, #000); border-color: transparent; }
.summary__item + .summary__item { border-left: 1px solid var(--border); }
.summary span { color: var(--muted); font-weight: 600; }
.summary strong { margin-top: 2px; font-size: 30px; line-height: 1.2; }
.tabs { display: flex; gap: 30px; margin-top: 24px; border-bottom: 1px solid var(--border); } .tabs { display: flex; gap: 30px; margin-top: 24px; border-bottom: 1px solid var(--border); }
.tab { position: relative; padding: 14px 4px 15px; border: 0; background: transparent; color: var(--muted); cursor: pointer; font-weight: 650; } .tab { position: relative; padding: 14px 4px 15px; border: 0; background: transparent; color: var(--muted); cursor: pointer; font-weight: 650; }
.tab:hover, .tab.is-active { color: var(--accent); } .tab:hover, .tab.is-active { color: var(--accent); }
.tab.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--accent); content: ""; } .tab.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--accent); content: ""; }
.panel { margin-top: 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; } .panel { margin-top: 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
.panel[hidden] { display: none; } .panel[hidden] { display: none; }
.control-mode { background: var(--surface-subtle); border-bottom: 1px solid var(--border); }
.control-mode__intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 0; padding-bottom: 16px; }
.current-mode { display: grid; flex: 0 0 auto; min-width: 210px; gap: 2px; padding: 13px 17px; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); border-radius: 10px; background: var(--accent-soft); box-shadow: inset 4px 0 0 var(--accent); }
.current-mode span { color: var(--muted); font-size: 12px; font-weight: 700; }
.current-mode strong { color: var(--accent); font-size: 20px; line-height: 1.35; }
.mode-options { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)) auto; align-items: stretch; gap: 12px; padding: 0 22px 22px; }
.mode-option { position: relative; display: flex; grid-template-columns: none; align-items: flex-start; gap: 10px; min-height: 76px; padding: 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); cursor: pointer; font-size: 14px; }
.mode-option:hover, .mode-option.is-selected { border-color: color-mix(in srgb, var(--accent) 58%, var(--border)); background: var(--accent-soft); }
.mode-option.is-active-mode::after { position: absolute; top: 8px; right: 8px; padding: 2px 7px; border-radius: 999px; background: var(--accent); color: #fff; content: "当前"; font-size: 11px; font-weight: 700; }
.mode-option input { flex: 0 0 auto; width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--accent); box-shadow: none; }
.mode-option span { display: grid; gap: 3px; }
.mode-option strong { color: var(--text); font-size: 14px; }
.mode-option small { color: var(--muted); font-size: 12px; font-weight: 500; line-height: 1.4; }
.mode-options .button { align-self: center; }
.editor { display: grid; grid-template-columns: minmax(260px, 1.35fr) minmax(210px, 1fr) auto; align-items: end; gap: 16px; padding: 22px; background: var(--surface-subtle); border-bottom: 1px solid var(--border); } .editor { display: grid; grid-template-columns: minmax(260px, 1.35fr) minmax(210px, 1fr) auto; align-items: end; gap: 16px; padding: 22px; background: var(--surface-subtle); border-bottom: 1px solid var(--border); }
.editor--control { grid-template-columns: minmax(250px, 1.5fr) minmax(150px, .7fr) minmax(190px, 1fr) auto; }
label { display: grid; gap: 7px; color: var(--text); font-size: 13px; font-weight: 650; } label { display: grid; gap: 7px; color: var(--text); font-size: 13px; font-weight: 650; }
input { width: 100%; height: 42px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); transition: border 160ms ease, box-shadow 160ms ease; } input, select { width: 100%; height: 42px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); transition: border 160ms ease, box-shadow 160ms ease; }
input::placeholder { color: color-mix(in srgb, var(--muted) 76%, transparent); } input::placeholder { color: color-mix(in srgb, var(--muted) 76%, transparent); }
input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); outline: none; } input:focus, select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); outline: none; }
.section-intro { padding: 22px; border-bottom: 1px solid var(--border); } .section-intro { padding: 22px; border-bottom: 1px solid var(--border); }
.section-intro--actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.section-intro h2 { margin: 0; font-size: 18px; } .section-intro h2 { margin: 0; font-size: 18px; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
table { width: 100%; table-layout: auto; border-collapse: collapse; } table { width: 100%; table-layout: auto; border-collapse: collapse; }
@@ -74,12 +87,16 @@ tbody tr:hover { background: color-mix(in srgb, var(--accent-soft) 42%, transpar
tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, transparent); } tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, transparent); }
.alias-cell { min-width: 320px; } .alias-cell { min-width: 320px; }
.inline-editor { min-width: 260px; } .inline-editor { min-width: 260px; }
.inline-editor--compact { min-width: 130px; }
.actions { white-space: nowrap; } .actions { white-space: nowrap; }
.link-button { padding: 4px 7px; border: 0; border-radius: 5px; background: transparent; color: #3568ae; cursor: pointer; font-weight: 650; } .link-button { padding: 4px 7px; border: 0; border-radius: 5px; background: transparent; color: #3568ae; cursor: pointer; font-weight: 650; }
[data-theme="dark"] .link-button { color: #8db9f4; } [data-theme="dark"] .link-button { color: #8db9f4; }
.link-button:hover { background: color-mix(in srgb, currentColor 10%, transparent); } .link-button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.link-button:disabled { cursor: wait; opacity: .55; } .link-button:disabled { cursor: wait; opacity: .55; }
.link-button--danger { color: var(--danger); } .link-button--danger { color: var(--danger); }
.list-badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.list-badge--whitelist { background: color-mix(in srgb, var(--success) 12%, transparent); color: var(--success); }
.list-badge--blacklist { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); }
.state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; } .state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; }
.state::before { width: 8px; height: 8px; border-radius: 50%; background: currentColor; content: ""; } .state::before { width: 8px; height: 8px; border-radius: 50%; background: currentColor; content: ""; }
.state--on { color: var(--success); } .state--on { color: var(--success); }
@@ -94,12 +111,14 @@ tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, tra
@media (max-width: 760px) { @media (max-width: 760px) {
.shell { width: min(100% - 24px, 1260px); padding-top: 20px; } .shell { width: min(100% - 24px, 1260px); padding-top: 20px; }
.page-header { align-items: stretch; flex-direction: column; } .page-header { align-items: stretch; flex-direction: column; }
.summary { grid-template-columns: 1fr; } .section-intro--actions { align-items: stretch; flex-direction: column; }
.summary__item { min-height: 86px; padding: 16px 20px; }
.summary__item + .summary__item { border-top: 1px solid var(--border); border-left: 0; }
.tabs { gap: 16px; overflow-x: auto; } .tabs { gap: 16px; overflow-x: auto; }
.tab { flex: 0 0 auto; } .tab { flex: 0 0 auto; }
.editor { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; }
.mode-options { grid-template-columns: 1fr; }
.mode-options .button { width: 100%; }
.control-mode__intro { align-items: stretch; flex-direction: column; }
.current-mode { min-width: 0; }
.table-wrap { overflow: visible; } .table-wrap { overflow: visible; }
table, tbody, tr, td { display: block; width: 100%; } table, tbody, tr, td { display: block; width: 100%; }
thead { display: none; } thead { display: none; }
+137 -133
View File
@@ -1,197 +1,201 @@
{# template-title: 剑网三插件功能介绍 #} {# template-title: 剑网三插件功能介绍 #}
{# 每行说明可填写在 command-row 的 data-description 属性中,例如:data-description="这里填写说明" #}
<div class="page help-page"> <div class="page help-page">
<header class="top-info help-header"> <header class="top-info help-header">
<div> <div>
<div class="top-name">剑网三综合查询插件</div> <div class="top-name">剑网三综合查询插件</div>
<div class="top-summary">共 17 个功能分类 · 69 项功能 · 93 个触发</div> <div class="top-summary">18 个功能分 · 92 个触发指令</div>
</div> </div>
<div class="top-desc"> <div class="help-rules">
指令与参数之间使用空格分隔<br> <span>参数之间使用空格分隔</span>
带有 [ ] 的参数为可选参数 <span><strong>[ ]</strong> 内为可选参数</span>
</div> </div>
</header> </header>
<main class="section-grid"> <main class="command-directory">
<section class="section span-2"> <div class="command-columns" aria-hidden="true">
<div class="section-title">活动情报</div> <span>功能</span><span>触发指令</span><span>说明</span>
<div class="section-body"> </div>
<div class="feature"><div class="feature-name">活动日历</div><div class="commands"><span class="command">日常 [延后天数]</span><span class="command">日常预测</span></div></div> <section class="command-section">
<div class="feature"><div class="feature-name">声望社</div><div class="commands"><span class="command">楚天社</span><span class="command">云从社</span><span class="command">披风会</span><span class="command">穹野卫</span></div></div> <header class="command-section__header"><span class="section-index">01</span><h2>活动情报</h2><span class="section-count">13 个触发词</span></header>
<div class="feature"><div class="feature-name">关隘首领</div><div class="commands"><span class="command">关隘</span></div></div> <div class="command-list">
<div class="feature"><div class="feature-name">赤兔</div><div class="commands"><span class="command">赤兔</span><span class="command">本周赤兔</span></div></div> <div class="command-row"><div class="feature-name">活动日历</div><div class="commands"><code class="command">日常 [延后天数]</code><code class="command">日常预测</code></div></div>
<div class="feature"><div class="feature-name">阵营事件</div><div class="commands"><span class="command">阵营奉献 [阵营]</span></div></div> <div class="command-row"><div class="feature-name">声望社</div><div class="commands"><code class="command">楚天社</code><code class="command">云从社</code><code class="command">披风会</code><code class="command">穹野卫</code></div></div>
<div class="feature"><div class="feature-name">烟花记录</div><div class="commands"><span class="command">烟花 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">关隘首领</div><div class="commands"><code class="command">关隘</code></div></div>
<div class="feature"><div class="feature-name">马场查询</div><div class="commands"><span class="command">刷马 服务器</span><span class="command">马场 服务器</span></div></div> <div class="command-row"><div class="feature-name">赤兔记录</div><div class="commands"><code class="command">赤兔</code><code class="command">本周赤兔</code></div></div>
<div class="command-row"><div class="feature-name">阵营事件</div><div class="commands"><code class="command">阵营奉献 [阵营]</code></div></div>
<div class="command-row"><div class="feature-name">烟花记录</div><div class="commands"><code class="command">烟花 服务器 角色</code></div></div>
<div class="command-row"><div class="feature-name">马场查询</div><div class="commands"><code class="command">刷马 服务器</code><code class="command">马场 服务器</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">名剑相关</div> <header class="command-section__header"><span class="section-index">02</span><h2>名剑相关</h2><span class="section-count">3 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">名剑战绩</div><div class="commands"><span class="command">战绩 服务器 角色 [模式]</span></div></div> <div class="command-row" data-description="模式可选22、33、55"><div class="feature-name">名剑战绩</div><div class="commands"><code class="command">战绩 服务器 角色 [模式]</code></div></div>
<div class="feature"><div class="feature-name">名剑排行</div><div class="commands"><span class="command">名剑排行 [模式] [数量]</span></div></div> <div class="command-row"><div class="feature-name">名剑排行</div><div class="commands"><code class="command">名剑排行 [模式] [数量]</code></div></div>
<div class="feature"><div class="feature-name">名剑统计</div><div class="commands"><span class="command">名剑统计 [模式]</span></div></div> <div class="command-row"><div class="feature-name">名剑统计</div><div class="commands"><code class="command">名剑统计 [模式]</code></div></div>
</div> </div>
</section> </section>
<section class="section span-3 ranking-section"> <section class="command-section">
<div class="section-title">排行榜单</div> <header class="command-section__header"><span class="section-index">03</span><h2>排行榜单</h2><span class="section-count">4 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature ranking-feature"> <div class="command-row"><div class="feature-name">分类排行</div><div class="commands"><code class="command">帮会排行 服务器</code><code class="command">阵营排行 服务器</code><code class="command">其他排行 服务器</code></div></div>
<div class="feature-name">排行统计(二轮选择)</div> <div class="command-row"><div class="feature-name">试炼排行</div><div class="commands"><code class="command">试炼排行 服务器 心法</code></div></div>
<div class="ranking-list">
<span class="command">帮会排行 服务器</span>
<span class="command">阵营排行 服务器</span>
<span class="command">其他排行 服务器</span>
</div>
</div>
<div class="feature trial-feature"><div class="feature-name">试炼排行</div><div class="commands"><span class="command">试炼排行 服务器 心法</span></div></div>
</div> </div>
</section> </section>
<section class="section span-2"> <section class="command-section">
<div class="section-title">物价交易</div> <header class="command-section__header"><span class="section-index">04</span><h2>资历相关</h2><span class="section-count">2 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">阵营拍卖</div><div class="commands"><span class="command">阵营拍卖 服务器 [物品] [数量]</span></div></div> <div class="command-row"><div class="feature-name">角色资历</div><div class="commands"><code class="command">资历 服务器 角色</code></div></div>
<div class="feature"><div class="feature-name">的卢拍卖</div><div class="commands"><span class="command">的卢 服务器</span></div></div> <div class="command-row"><div class="feature-name">资历排行</div><div class="commands"><code class="command">资历排行 [服务器] [门派]</code></div></div>
<div class="feature"><div class="feature-name">金价行情</div><div class="commands"><span class="command">金价 服务器</span></div></div>
<div class="feature"><div class="feature-name">物品价格</div><div class="commands"><span class="command">物价 外观名称 [服务器]</span></div></div>
<div class="feature"><div class="feature-name">成本计算</div><div class="commands"><span class="command">成本 服务器 物品名称</span></div></div>
<div class="feature"><div class="feature-name">编号搜索</div><div class="commands"><span class="command">看号 万宝楼编号</span></div></div>
<div class="feature"><div class="feature-name">交易行</div><div class="commands"><span class="command">交易行 服务器 物品</span></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">阵营战场</div> <header class="command-section__header"><span class="section-index">05</span><h2>物价交易</h2><span class="section-count">7 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">帮战记录</div><div class="commands"><span class="command">帮战 服务器</span></div></div> <div class="command-row"><div class="feature-name">阵营拍卖</div><div class="commands"><code class="command">阵营拍卖 服务器 [物品] [数量]</code></div></div>
<div class="feature"><div class="feature-name">阵营沙盘</div><div class="commands"><span class="command">沙盘 服务器</span></div></div> <div class="command-row"><div class="feature-name">的卢拍卖</div><div class="commands"><code class="command">的卢 服务器</code></div></div>
<div class="feature"><div class="feature-name">诛恶事件</div><div class="commands"><span class="command">诛恶 服务器</span></div></div> <div class="command-row"><div class="feature-name">金价行情</div><div class="commands"><code class="command">金价 服务器</code></div></div>
<div class="command-row"><div class="feature-name">物品价格</div><div class="commands"><code class="command">物价 外观名称 [服务器]</code></div></div>
<div class="command-row"><div class="feature-name">成本计算</div><div class="commands"><code class="command">成本 服务器 物品名称</code></div></div>
<div class="command-row"><div class="feature-name">编号搜索</div><div class="commands"><code class="command">看号 万宝楼编号</code></div></div>
<div class="command-row"><div class="feature-name">交易行</div><div class="commands"><code class="command">交易行 服务器 物品</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">角色名片</div> <header class="command-section__header"><span class="section-index">06</span><h2>阵营战场</h2><span class="section-count">3 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">名片缓存</div><div class="commands"><span class="command">名片 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">帮战记录</div><div class="commands"><code class="command">帮战 服务器</code></div></div>
<div class="feature"><div class="feature-name">名片历史</div><div class="commands"><span class="command">全名片 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">阵营沙盘</div><div class="commands"><code class="command">沙盘 服务器</code></div></div>
<div class="feature"><div class="feature-name">随机名片</div><div class="commands"><span class="command">随机秀 服务器 [门派] [体型]</span></div></div> <div class="command-row"><div class="feature-name">诛恶事件</div><div class="commands"><code class="command">诛恶 服务器</code></div></div>
</div> </div>
</section> </section>
<section class="section span-2"> <section class="command-section">
<div class="section-title">奇遇相关</div> <header class="command-section__header"><span class="section-index">07</span><h2>角色名片</h2><span class="section-count">3 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">奇遇记录</div><div class="commands"><span class="command">奇遇 服务器 角色</span><span class="command">查询 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">名片缓存</div><div class="commands"><code class="command">名片 服务器 角色</code></div></div>
<div class="feature"><div class="feature-name">未触发的</div><div class="commands"><span class="command">未出 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">名片历史</div><div class="commands"><code class="command">全名片 服务器 角色</code></div></div>
<div class="feature"><div class="feature-name">奇遇汇总</div><div class="commands"><span class="command">汇总 服务器 [天数]</span></div></div> <div class="command-row"><div class="feature-name">随机名片</div><div class="commands"><code class="command">随机秀 服务器 [门派] [体型]</code></div></div>
<div class="feature"><div class="feature-name">近期奇遇</div><div class="commands"><span class="command">近期 服务器 [数量]</span></div></div>
<div class="feature"><div class="feature-name">奇遇统计</div><div class="commands"><span class="command">统计 奇遇 [服务器] [数量]</span></div></div>
<div class="feature"><div class="feature-name">奇遇攻略</div><div class="commands"><span class="command">攻略 奇遇</span></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">百战记录</div> <header class="command-section__header"><span class="section-index">08</span><h2>奇遇相关</h2><span class="section-count">7 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">角色百战</div><div class="commands"><span class="command">精耐 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">奇遇记录</div><div class="commands"><code class="command">奇遇 服务器 角色</code><code class="command">查询 服务器 角色</code></div></div>
<div class="feature"><div class="feature-name">百战首领</div><div class="commands"><span class="command">百战</span></div></div> <div class="command-row"><div class="feature-name">未触发奇遇</div><div class="commands"><code class="command">未出 服务器 角色</code></div></div>
<div class="command-row"><div class="feature-name">奇遇汇总</div><div class="commands"><code class="command">汇总 服务器 [天数]</code></div></div>
<div class="command-row"><div class="feature-name">近期奇遇</div><div class="commands"><code class="command">近期 服务器 [数量]</code></div></div>
<div class="command-row"><div class="feature-name">奇遇统计</div><div class="commands"><code class="command">统计 奇遇 [服务器] [数量]</code></div></div>
<div class="command-row"><div class="feature-name">奇遇攻略</div><div class="commands"><code class="command">攻略 奇遇</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">角色信息</div> <header class="command-section__header"><span class="section-index">09</span><h2>百战记录</h2><span class="section-count">2 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">成就查询</div><div class="commands"><span class="command">成就 服务器 角色 成就</span></div></div> <div class="command-row"><div class="feature-name">角色百战</div><div class="commands"><code class="command">精耐 服务器 角色</code></div></div>
<div class="feature"><div class="feature-name">角色详情</div><div class="commands"><span class="command">角色 服务器 名称</span></div></div> <div class="command-row"><div class="feature-name">百战首领</div><div class="commands"><code class="command">百战</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">资历相关</div> <header class="command-section__header"><span class="section-index">10</span><h2>角色信息</h2><span class="section-count">2 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">资历查询</div><div class="commands"><span class="command">资历 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">成就查询</div><div class="commands"><code class="command">成就 服务器 角色 成就</code></div></div>
<div class="feature"><div class="feature-name">资历榜单</div><div class="commands"><span class="command">资历排行 [服务器] [门派]</span></div></div> <div class="command-row"><div class="feature-name">角色详情</div><div class="commands"><code class="command">角色 服务器 名称</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">心法配装</div> <header class="command-section__header"><span class="section-index">11</span><h2>心法配装</h2><span class="section-count">6 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">阵眼效果</div><div class="commands"><span class="command">阵眼 心法</span></div></div> <div class="command-row"><div class="feature-name">阵眼效果</div><div class="commands"><code class="command">阵眼 心法</code></div></div>
<div class="feature"><div class="feature-name">魔盒配装</div><div class="commands"><span class="command">配装 心法</span></div></div> <div class="command-row"><div class="feature-name">魔盒配装</div><div class="commands"><code class="command">配装 心法</code></div></div>
<div class="feature"><div class="feature-name">技能信息</div><div class="commands"><span class="command">技能 心法</span></div></div> <div class="command-row"><div class="feature-name">技能信息</div><div class="commands"><code class="command">技能 心法</code></div></div>
<div class="feature"><div class="feature-name">奇穴信息</div><div class="commands"><span class="command">奇穴 心法</span></div></div> <div class="command-row"><div class="feature-name">奇穴信息</div><div class="commands"><code class="command">奇穴 心法</code></div></div>
<div class="feature"><div class="feature-name">魔盒宏</div><div class="commands"><span class="command">宏 心法</span></div></div> <div class="command-row"><div class="feature-name">魔盒宏</div><div class="commands"><code class="command">宏 心法</code></div></div>
<div class="command-row"><div class="feature-name">小药搭配</div><div class="commands"><code class="command">小药 心法</code></div></div>
</div> </div>
</section> </section>
<section class="section span-2"> <section class="command-section">
<div class="section-title">游戏社区</div> <header class="command-section__header"><span class="section-index">12</span><h2>游戏社区</h2><span class="section-count">8 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">角色聊天</div><div class="commands"><span class="command">聊天 服务器 角色 [条数] [页数]</span></div></div> <div class="command-row"><div class="feature-name">角色发言</div><div class="commands"><code class="command">发言 服务器 角色 [条数] [页数]</code></div></div>
<div class="feature"><div class="feature-name">统战歪歪</div><div class="commands"><span class="command">统战 [服务器]</span></div></div> <div class="command-row"><div class="feature-name">统战歪歪</div><div class="commands"><code class="command">统战 [服务器]</code></div></div>
<div class="feature"><div class="feature-name">师徒列表</div><div class="commands"><span class="command">拜师 服务器 [关键词]</span><span class="command">收徒 服务器 [关键词]</span></div></div> <div class="command-row"><div class="feature-name">骗子查询</div><div class="commands"><code class="command">骗子 UID [服务器]</code></div></div>
<div class="feature"><div class="feature-name">团队招募</div><div class="commands"><span class="command">招募 服务器 [副本]</span><span class="command">团长 服务器 [名称]</span><span class="command">团牌 服务器 [内容]</span></div></div> <div class="command-row"><div class="feature-name">师徒列表</div><div class="commands"><code class="command">拜师 服务器 [关键词]</code><code class="command">收徒 服务器 [关键词]</code></div></div>
<div class="feature full-row"><div class="feature-name">骚话语录</div><div class="commands"><span class="command">答案之书</span><span class="command">舔狗语录</span><span class="command">疯狂星期四</span><span class="command">彩虹屁</span><span class="command">毒鸡汤</span><span class="command">朋友圈</span><span class="command">喝什么</span><span class="command">吃什么</span><span class="command">骚话</span><span class="command">渣男语录</span></div></div> <div class="command-row"><div class="feature-name">团队招募</div><div class="commands"><code class="command">招募 服务器 [副本]</code><code class="command">团长 服务器 [名称]</code><code class="command">团牌 服务器 [内容]</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">百度贴吧</div> <header class="command-section__header"><span class="section-index">13</span><h2>家园杂项</h2><span class="section-count">3 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">外观记录</div><div class="commands"><span class="command">贴吧物价 名称 [服务器] [数量]</span></div></div> <div class="command-row"><div class="feature-name">家园鲜花</div><div class="commands"><code class="command">花价 服务器 [名称] [地图]</code></div></div>
<div class="feature"><div class="feature-name">百度贴吧</div><div class="commands"><span class="command">818 [服务器] [数量]</span></div></div> <div class="command-row"><div class="feature-name">家园装饰</div><div class="commands"><code class="command">装饰 名称</code></div></div>
<div class="command-row"><div class="feature-name">器物图谱</div><div class="commands"><code class="command">器物 地图名称</code></div></div>
</div> </div>
</section> </section>
<section class="section span-2"> <section class="command-section">
<div class="section-title">系统工具</div> <header class="command-section__header"><span class="section-index">14</span><h2>系统工具</h2><span class="section-count">8 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">维护公告</div><div class="commands"><span class="command">维护 [数量]</span></div></div> <div class="command-row"><div class="feature-name">维护公告</div><div class="commands"><code class="command">维护 [数量]</code></div></div>
<div class="feature"><div class="feature-name">新闻资讯</div><div class="commands"><span class="command">新闻 [数量]</span></div></div> <div class="command-row"><div class="feature-name">新闻资讯</div><div class="commands"><code class="command">新闻 [数量]</code></div></div>
<div class="feature"><div class="feature-name">大区状态</div><div class="commands"><span class="command">区服</span><span class="command">开服 服务器</span></div></div> <div class="command-row"><div class="feature-name">科举答案</div><div class="commands"><code class="command">科举 题目 [条数]</code></div></div>
<div class="feature"><div class="feature-name">技改记录</div><div class="commands"><span class="command">技改</span></div></div> <div class="command-row"><div class="feature-name">大区状态</div><div class="commands"><code class="command">区服</code><code class="command">开服 服务器</code></div></div>
<div class="feature"><div class="feature-name">秘境方位</div><div class="commands"><span class="command">解密</span></div></div> <div class="command-row"><div class="feature-name">技改记录</div><div class="commands"><code class="command">技改</code></div></div>
<div class="feature"><div class="feature-name">副本记录</div><div class="commands"><span class="command">副本 服务器 角色</span></div></div> <div class="command-row"><div class="feature-name">秘境方位</div><div class="commands"><code class="command">解密</code></div></div>
<div class="feature"><div class="feature-name">掉落统计</div><div class="commands"><span class="command">掉落 物品 [服务器] [数量]</span></div></div> <div class="command-row"><div class="feature-name">掉落统计</div><div class="commands"><code class="command">掉落 物品 [服务器] [数量]</code></div></div>
</div> </div>
</section> </section>
<section class="section"> <section class="command-section">
<div class="section-title">杂项功能</div> <header class="command-section__header"><span class="section-index">15</span><h2>趣味互动</h2><span class="section-count">10 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">家园鲜花</div><div class="commands"><span class="command">花价 服务器 [名称] [地图]</span></div></div> <div class="command-row"><div class="feature-name">随机回答</div><div class="commands"><code class="command">答案之书</code><code class="command">喝什么</code><code class="command">吃什么</code></div></div>
<div class="feature"><div class="feature-name">家园装饰</div><div class="commands"><span class="command">装饰 名称</span></div></div> <div class="command-row"><div class="feature-name">语录生成</div><div class="commands"><code class="command">舔狗语录</code><code class="command">疯狂星期四</code><code class="command">彩虹屁</code><code class="command">毒鸡汤</code><code class="command">朋友圈</code><code class="command">骚话</code><code class="command">渣男语录</code></div></div>
<div class="feature"><div class="feature-name">器物图谱</div><div class="commands"><span class="command">器物 地图名称</span></div></div>
<div class="feature"><div class="feature-name">科举答案</div><div class="commands"><span class="command">科举 题目 [条数]</span></div></div>
</div> </div>
</section> </section>
<section class="section span-2 section--danger"> <section class="command-section">
<div class="section-title">避雷功能</div> <header class="command-section__header"><span class="section-index">16</span><h2>百度贴吧</h2><span class="section-count">2 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">避雷添加</div><div class="commands"><span class="command">避雷添加 名称 备注</span></div></div> <div class="command-row"><div class="feature-name">外观记录</div><div class="commands"><code class="command">贴吧物价 名称 [服务器] [数量]</code></div></div>
<div class="feature"><div class="feature-name">避雷查看</div><div class="commands"><span class="command">避雷查看</span></div></div> <div class="command-row"><div class="feature-name">贴吧内容</div><div class="commands"><code class="command">818 [服务器] [数量]</code></div></div>
<div class="feature"><div class="feature-name">避雷查询</div><div class="commands"><span class="command">避雷查询 名称</span></div></div>
<div class="feature"><div class="feature-name">避雷修改</div><div class="commands"><span class="command">避雷修改 ID 名称 备注</span></div></div>
<div class="feature"><div class="feature-name">避雷删除</div><div class="commands"><span class="command">避雷删除 ID</span></div></div>
</div> </div>
</section> </section>
<section class="section span-3 section--success push-section"> <section class="command-section command-section--success">
<div class="section-title">推送功能</div> <header class="command-section__header"><span class="section-index">17</span><h2>插件与推送</h2><span class="section-count">4 个触发词</span></header>
<div class="section-body"> <div class="command-list">
<div class="feature"><div class="feature-name">绑定区服</div><div class="commands"><span class="command">绑定区服 [区服]</span><span class="command">解绑区服</span></div></div> <div class="command-row"><div class="feature-name">功能帮助</div><div class="commands"><code class="command">功能</code></div></div>
<div class="feature"><div class="feature-name">事件总开关</div><div class="commands"><span class="command">事件推送 开启/关闭</span></div></div> <div class="command-row"><div class="feature-name">区服绑定</div><div class="commands"><code class="command">绑定区服 [区服]</code><code class="command">解绑区服</code></div></div>
<div class="feature"><div class="feature-name">订阅事件</div><div class="commands"><span class="command">事件推送 编号 开启/关闭</span></div></div> <div class="command-row"><div class="feature-name">事件推送</div><div class="commands"><code class="command">事件推送 [状态/列表]</code><code class="command">事件推送 开启/关闭</code><code class="command">事件推送 编号 开启/关闭</code></div></div>
<div class="feature"><div class="feature-name">订阅状态</div><div class="commands"><span class="command">事件推送 [状态/列表]</span></div></div> </div>
</section>
<section class="command-section command-section--danger">
<header class="command-section__header"><span class="section-index">18</span><h2>避雷功能</h2><span class="section-count">5 个触发词</span></header>
<div class="command-list">
<div class="command-row"><div class="feature-name">新增记录</div><div class="commands"><code class="command">避雷添加 名称 备注</code></div></div>
<div class="command-row"><div class="feature-name">查看记录</div><div class="commands"><code class="command">避雷查看</code></div></div>
<div class="command-row"><div class="feature-name">查询记录</div><div class="commands"><code class="command">避雷查询 名称</code></div></div>
<div class="command-row"><div class="feature-name">修改记录</div><div class="commands"><code class="command">避雷修改 ID 名称 备注</code></div></div>
<div class="command-row"><div class="feature-name">删除记录</div><div class="commands"><code class="command">避雷删除 ID</code></div></div>
</div> </div>
</section> </section>
</main> </main>
<footer class="footer help-footer"> <footer class="footer help-footer">
指令与参数之间使用空格分隔,带有 <strong>[ ]</strong> 的参数可以省略;会话绑定区服后可省略区服参数在区服位置填写“全区”可查询全区数据。 会话绑定区服后可省略指令中的区服参数在区服位置填写“全区”可显式查询全区数据。
</footer> </footer>
</div> </div>
+167 -116
View File
@@ -1,167 +1,218 @@
.help-page { .help-page {
position: relative;
padding: 22px; padding: 22px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--jx3-border-soft); border: 1px solid var(--jx3-border-soft);
border-radius: 20px; border-radius: var(--jx3-radius-lg);
background: linear-gradient(135deg, #ffffff, #f5f8fa); background: linear-gradient(180deg, #ffffff, #f6f8f9);
box-shadow: var(--jx3-shadow-sm); box-shadow: var(--jx3-shadow-sm);
} }
.help-header { .help-page .help-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 28px;
margin-bottom: 18px; margin-bottom: 18px;
} }
.top-name { .help-page .top-name {
color: var(--jx3-gold-bright); color: var(--jx3-gold-bright);
font-size: 32px; font-size: 36px;
line-height: 1.3;
font-weight: 800;
letter-spacing: 0.06em;
}
.help-page .top-summary {
margin-top: 4px;
color: var(--jx3-text-muted);
font-size: 20px;
}
.help-rules {
display: grid;
gap: 5px;
min-width: 300px;
color: var(--jx3-text-secondary);
font-size: 20px;
text-align: right;
}
.help-rules strong {
color: var(--jx3-gold-bright);
}
.command-directory {
overflow: hidden;
border: 1px solid var(--jx3-border-soft);
border-radius: var(--jx3-radius-md);
background: var(--jx3-surface);
box-shadow: var(--jx3-shadow-sm);
}
.command-columns {
display: grid;
grid-template-columns: 210px minmax(0, 1fr) 360px;
border-bottom: 1px solid var(--jx3-border-soft);
background: var(--jx3-surface-muted);
color: var(--jx3-text-muted);
font-size: 17px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.top-summary { .command-columns span {
margin-top: 5px; padding: 10px 18px;
font-size: 18px;
} }
.top-desc { .command-columns span + span {
font-size: 18px; border-left: 1px solid var(--jx3-border-faint);
line-height: 1.75;
text-align: right;
} }
.section-grid { .command-section + .command-section {
border-top: 2px solid var(--jx3-border-soft);
}
.command-section__header {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: 54px minmax(0, 1fr) auto;
gap: 16px; align-items: center;
align-items: start; gap: 14px;
min-height: 66px;
padding: 10px 20px;
background: linear-gradient(90deg, #f7efdf, #f5f8fa 76%);
} }
.section { .command-section__header h2 {
min-width: 0; margin: 0;
overflow: hidden; color: var(--jx3-text);
} font-size: 25px;
line-height: 1.35;
.section.span-2 {
grid-column: span 2;
}
.section.span-3,
.full-row {
grid-column: 1 / -1;
}
.section-title {
padding: 11px 15px;
border-bottom: 1px solid rgba(220, 199, 145, 0.3);
background: linear-gradient(90deg, rgba(198, 165, 93, 0.24), rgba(105, 138, 194, 0.08));
font-size: 22px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.05em; letter-spacing: 0.04em;
} }
.section-body { .section-index {
display: grid; display: inline-flex;
grid-template-columns: 1fr; width: 42px;
gap: 0 18px; height: 42px;
padding: 7px 13px 10px; align-items: center;
} justify-content: center;
border: 1px solid var(--jx3-border-soft);
.span-2 .section-body { border-radius: 50%;
grid-template-columns: repeat(2, minmax(0, 1fr)); background: #ffffff;
} color: var(--jx3-gold-bright);
.feature {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 9px;
align-items: start;
min-width: 0;
padding: 8px 0;
border-bottom: 1px dashed rgba(186, 205, 239, 0.15);
}
.feature-name {
padding-top: 3px;
font-size: 18px; font-size: 18px;
line-height: 1.5; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.section-count {
padding: 3px 10px;
border: 1px solid var(--jx3-border-faint);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: var(--jx3-text-muted);
font-size: 17px;
font-weight: 700; font-weight: 700;
} }
.commands { .command-list {
border-top: 1px solid var(--jx3-border-faint);
}
.command-row {
display: grid;
grid-template-columns: 210px minmax(0, 1fr) 360px;
align-items: center;
min-height: 64px;
border-bottom: 1px solid var(--jx3-border-faint);
}
.command-row::after {
align-self: stretch;
display: flex;
align-items: center;
min-width: 0;
padding: 10px 18px;
border-left: 1px solid var(--jx3-border-faint);
background: rgba(246, 249, 250, 0.62);
color: var(--jx3-text-secondary);
content: attr(data-description);
font-size: 19px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.command-row:last-child {
border-bottom: 0;
}
.command-row:nth-child(even) {
background: var(--jx3-surface-strong);
}
.help-page .feature-name {
align-self: stretch;
display: flex;
align-items: center;
padding: 10px 18px 10px 22px;
border-right: 1px solid var(--jx3-border-faint);
color: var(--jx3-text);
font-size: 21px;
line-height: 1.4;
font-weight: 700;
}
.help-page .commands {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px; gap: 7px;
min-width: 0; min-width: 0;
padding: 9px 16px;
} }
.command { .help-page .command {
display: inline-block; display: inline-flex;
padding: 3px 7px; align-items: center;
border: 1px solid rgba(157, 184, 228, 0.22); min-height: 34px;
border-radius: 5px; padding: 3px 10px;
font-size: 18px; border: 1px solid #cbd9e3;
line-height: 1.5; border-radius: var(--jx3-radius-sm);
word-break: break-all; background: #edf4f8;
color: var(--jx3-text-secondary);
font-family: inherit;
font-size: 20px;
line-height: 1.45;
font-weight: 600;
white-space: normal;
word-break: break-word;
} }
.ranking-section .section-body { .command-section--success .command-section__header {
display: block; background: linear-gradient(90deg, #e6f3eb, #f5f8f6 76%);
} }
.ranking-feature { .command-section--success .section-index {
grid-template-columns: 88px minmax(0, 1fr); border-color: rgba(32, 117, 77, 0.35);
color: var(--jx3-green);
} }
.ranking-list { .command-section--danger .command-section__header {
display: grid; background: linear-gradient(90deg, #f9e7e4, #faf7f5 76%);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
} }
.ranking-list .command { .command-section--danger .section-index {
width: 100%; border-color: rgba(178, 56, 50, 0.35);
color: var(--jx3-red);
} }
.trial-feature { .help-page .help-footer {
margin-top: 4px;
}
.push-section .section-body {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.section--danger {
border-color: rgba(213, 106, 99, 0.54) !important;
}
.section--danger .section-title {
color: #872d29;
background: linear-gradient(90deg, #f9dfdc, #f9f3ee);
}
.section--success {
border-color: rgba(98, 188, 146, 0.52) !important;
}
.section--success .section-title {
color: #176541;
background: linear-gradient(90deg, #dff2e8, #f3f7f4);
}
.help-footer {
margin-top: 17px; margin-top: 17px;
padding: 13px 16px 2px; padding: 13px 16px 2px;
border-top: 1px solid rgba(216, 195, 140, 0.24); border-top: 1px solid var(--jx3-border-soft);
font-size: 18px; color: var(--jx3-text-muted);
line-height: 1.8; font-size: 20px;
line-height: 1.7;
text-align: center; text-align: center;
} }
.help-footer strong {
color: var(--jx3-gold-bright);
}
+1 -1
View File
@@ -118,5 +118,5 @@
} }
.jx3-template--helps { .jx3-template--helps {
--jx3-page-width: 1520px; --jx3-page-width: 1480px;
} }