增加webui界面

This commit is contained in:
qsc
2026-09-03 17:33:28 +08:00
parent f28217ec27
commit f872ffae71
21 changed files with 1512 additions and 130 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"pages": {
"server-management": {
"title": "剑网三插件管理",
"description": "管理会话区服绑定、事件订阅状态、区服别名和心法别名。"
}
}
}
+16
View File
@@ -1,5 +1,21 @@
## 更新记录 ## 更新记录
### version: 3.4.0
移除插件级默认区服,新增按 AstrBot 会话保存的区服绑定。已绑定会话可省略查询指令中的区服参数,完整填写区服时仍优先查询指定区服。
新增区服别名配置,一个标准区服可以对应多个别名;别名可用于会话绑定和查询指令。
新增 `data/server_aliases.json` 区服别名种子,插件启动时幂等写入本地数据库,已有区服别名配置不会被覆盖。
事件正文含服务器时按每个目标会话的绑定区服分别过滤;未绑定会话继续接收全部服务器的已订阅事件。
新增 AstrBot 插件 WebUI 管理页,可管理会话区服绑定和区服别名,并查看所有会话的事件总开关及订阅事件。
新增心法别名 WebUI,可维护 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
移除随包 SQLite `data/plugin_data.db`,原有 32 条心法数据改为 JSON 种子并在启动时幂等写入本地 `local_data.db`,心法查询和配置统一使用本地数据库。
### version: 3.3.1 ### version: 3.3.1
沙盘功能改用 JX3API `/sand/records` 据点数据接口,并使用随插件分发的沙盘底图、阵营领地图层和据点图标在本地渲染完整图片。 沙盘功能改用 JX3API `/sand/records` 据点数据接口,并使用随插件分发的沙盘底图、阵营领地图层和据点图标在本地渲染完整图片。
+43 -20
View File
@@ -36,7 +36,7 @@
### 环境要求 ### 环境要求
- AstrBot `>= 4.11.0` - AstrBot `>= 4.24.1`(管理页依赖 Plugin Pages
- 能够运行当前 AstrBot 版本的 Python 环境 - 能够运行当前 AstrBot 版本的 Python 环境
- 能够访问插件使用的外部数据源 - 能够访问插件使用的外部数据源
- 图片类指令需要 AstrBot 的 HTML 渲染能力正常可用 - 图片类指令需要 AstrBot 的 HTML 渲染能力正常可用
@@ -80,13 +80,12 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `prefix.enable` | `bool` | `true` | 是否启用指令前缀检查 | | `prefix.enable` | `bool` | `true` | 是否启用指令前缀检查 |
| `prefix.text` | `string` | `剑三` | 指令前缀内容 | | `prefix.text` | `string` | `剑三` | 指令前缀内容 |
| `server` | `string` | `梦江南` | 查询指令未输入服务器时使用的默认服务器 |
| `jx3api_token` | `string` | 空 | JX3API Token | | `jx3api_token` | `string` | 空 | JX3API Token |
| `jx3api_ticket` | `string` | 空 | 部分名剑和心法接口需要的推栏 Ticket | | `jx3api_ticket` | `string` | 空 | 部分名剑和心法接口需要的推栏 Ticket |
| `jx3api_wss` | `string` | `wss://socket.nicemoe.cn` | JX3API 事件通道地址 | | `jx3api_wss` | `string` | `wss://socket.nicemoe.cn` | JX3API 事件通道地址 |
| `jx3api_wss_token` | `string` | 空 | 事件版令牌;免费事件无需填写 | | `jx3api_wss_token` | `string` | 空 | 事件版令牌;免费事件无需填写 |
会话订阅不再写入插件配置,而是通过 `事件推送` 指令保存本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。 会话区服绑定、区服别名和事件订阅均保存本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。
### Token 与 Ticket ### Token 与 Ticket
@@ -118,7 +117,18 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
- 当前分发器按空白切分消息,因此角色名、物品名、备注等单个参数不能包含空格。 - 当前分发器按空白切分消息,因此角色名、物品名、备注等单个参数不能包含空格。
- 未识别的消息会被忽略;已识别的指令会停止继续传播给其他插件。 - 未识别的消息会被忽略;已识别的指令会停止继续传播给其他插件。
- 缺少必填参数、数字转换失败或执行异常时,入口层统一回复 `参数错误或执行失败` - 缺少必填参数、数字转换失败或执行异常时,入口层统一回复 `参数错误或执行失败`
- 当前默认服务器并不会自动补齐所有可选服务器参数;除 `烟花` 外,请按指令表显式填写需要的服务器 - 未绑定区服时按指令表提供标准参数。绑定后可以省略 `server` 参数;显式填写完整参数时仍优先使用本次输入的区服
- 标准区服名和 WebUI 中配置的别名都可用于查询。对于区服后的可选参数,分发器通过当前有效区服目录和别名判断首个参数是显式区服还是后续参数。
### 会话区服绑定
| 指令 | 作用 |
| --- | --- |
| `绑定区服` | 查看当前会话的绑定状态 |
| `绑定区服 梦江南` | 将当前会话绑定到指定区服;支持区服别名 |
| `解绑区服` | 解除当前会话绑定 |
例如会话已经绑定 `梦江南` 后,`战绩 角色名 33` 等价于 `战绩 梦江南 角色名 33`;仍可发送 `战绩 唯我独尊 角色名 33` 临时查询其他区服。
## 指令参考 ## 指令参考
@@ -140,7 +150,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `关隘` | 查询关隘首领状态;图片 | Token | | `关隘` | 查询关隘首领状态;图片 | Token |
| `赤兔``本周赤兔` | 查询当日或本周赤兔记录;文本 | Token | | `赤兔``本周赤兔` | 查询当日或本周赤兔记录;文本 | Token |
| `阵营奉献 [阵营]` | 查询阵营奉献事件,固定最多 50 条;图片 | Token | | `阵营奉献 [阵营]` | 查询阵营奉献事件,固定最多 50 条;图片 | Token |
| `烟花 [服务器] [角色]` | 查询烟花记录;服务器为空时使用配置值;图片 | Token | | `烟花 服务器 [角色]` | 查询指定服务器的烟花记录;已绑定会话可省略服务器;图片 | Token |
| `刷马 服务器` | 查询刷马聊天情报;文本 | Token | | `刷马 服务器` | 查询刷马聊天情报;文本 | Token |
| `马场 服务器` | 查询未过期马场记录;文本 | Token | | `马场 服务器` | 查询未过期马场记录;文本 | Token |
@@ -194,7 +204,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| 指令 | 说明与输出 | 凭据 | | 指令 | 说明与输出 | 凭据 |
| --- | --- | --- | | --- | --- | --- |
| `帮战 服务器` | 帮战记录;图片 | 无 | | `帮战 服务器` | 帮战记录;图片 | 无 |
| `沙盘 [服务器]` | 查询 JX3API 据点归属并使用本地图层渲染阵营沙盘;服务器可省略 | Token | | `沙盘 服务器` | 查询 JX3API 据点归属并使用本地图层渲染阵营沙盘;已绑定会话可省略服务器 | Token |
| `诛恶 服务器` | 诛恶事件,固定最多 20 条;图片 | Token | | `诛恶 服务器` | 诛恶事件,固定最多 20 条;图片 | Token |
### 角色名片与奇遇 ### 角色名片与奇遇
@@ -290,7 +300,11 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `事件推送 2001 关闭` | 为当前会话取消订阅事件 `2001` | | `事件推送 2001 关闭` | 为当前会话取消订阅事件 `2001` |
| `事件推送 列表` | 查看支持的全部事件编号 | | `事件推送 列表` | 查看支持的全部事件编号 |
总开关和具体事件开关必须同时开启,消息才会发送到当前会话。免费事件为:`2001` 开服状态、`2002` 官方新闻、`2003` 版本更新、`2004` 八卦速报、`2005` 关隘首领、`2006` 云从预告。其他事件需要独立的事件版令牌。 总开关和具体事件开关必须同时开启,消息才会发送到当前会话。事件正文包含 `server` 时,只向绑定该区服和未绑定区服的订阅会话发送;不含服务器的新闻、版本更新等事件不受区服过滤影响。免费事件为:`2001` 开服状态、`2002` 官方新闻、`2003` 版本更新、`2004` 八卦速报、`2005` 关隘首领、`2006` 云从预告。其他事件需要独立的事件版令牌。
### WebUI 插件管理
AstrBot 插件详情页中的“剑网三插件管理”页面提供四个页签:会话绑定、事件订阅、区服别名和心法别名。页面可以新增、修改或解除任意会话绑定,查看所有会话的事件总开关及订阅编号,为每个标准区服配置多个别名,并维护心法的 JX3BOX 配装 ID、标准名称和最多 5 个别名。管理页通过 AstrBot Plugin Pages 桥接调用插件 Web API,不直接访问 Dashboard 凭据。
## 业务流程 ## 业务流程
@@ -301,11 +315,11 @@ flowchart LR
C --> D["MessageBuilder 参数适配"] C --> D["MessageBuilder 参数适配"]
D --> E1["JX3APIService"] D --> E1["JX3APIService"]
D --> E2["JX3BOXService"] D --> E2["JX3BOXService"]
D --> E3["BiLeidata / EventPushService"] D --> E3["BiLeidata / EventPushService / KungfuAliasService"]
E1 --> F["APIClient"] E1 --> F["APIClient"]
E2 --> F E2 --> F
F --> G["外部 HTTP 数据源"] F --> G["外部 HTTP 数据源"]
E3 --> H["plugin_data.db / local_data.db"] E3 --> H["local_data.db"]
E1 --> I["标准返回对象"] E1 --> I["标准返回对象"]
E2 --> I E2 --> I
E3 --> I E3 --> I
@@ -318,18 +332,21 @@ flowchart LR
`main.py` 中的 `Jx3ApiPlugin` 是插件入口。构造阶段完成以下工作: `main.py` 中的 `Jx3ApiPlugin` 是插件入口。构造阶段完成以下工作:
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. 创建本地数据库、两个数据服务、心法别名服务、避雷服务、事件推送服务和消息构建器。
异步初始化阶段会连接数据库并创建以下本地表: 异步初始化阶段会连接数据库并创建以下本地表:
- `bilei`:避雷记录。 - `bilei`:避雷记录。
- `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。 - `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。
- `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。
- `server_aliases`:保存标准区服名及其 JSON 别名列表。
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
- `achievement_cache`JSON 基础数据缓存及更新时间。 - `achievement_cache`JSON 基础数据缓存及更新时间。
随后连接随包的 `plugin_data.db`启动 JX3API WebSocket 事件通道,最后建立指令映射。插件停用时会关闭事件通道、两个 HTTP Session 和两个 SQLite 连接。 `kungfu` 表首次创建时从 `data/kungfu.json` 幂等导入 32 条默认心法,`server_aliases` 表从 `data/server_aliases.json` 幂等导入默认区服别名;已存在的本地记录均不会被覆盖。随后启动 JX3API WebSocket 事件通道建立指令映射。插件停用时会关闭事件通道、两个 HTTP Session 和本地 SQLite 连接。
### 2. 指令分发 ### 2. 指令分发
@@ -423,12 +440,13 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
### 6. 本地数据与缓存 ### 6. 本地数据与缓存
插件使用两个 SQLite 文件: 插件使用 AstrBot 插件数据目录中的 `local_data.db`。心法和区服别名默认目录分别以 `data/kungfu.json``data/server_aliases.json` 随插件分发,启动时幂等写入本地数据库,之后可通过 WebUI 修改。
| 文件 | 生命周期 | 内容 | | 文件 | 生命周期 | 内容 |
| --- | --- | --- | | --- | --- | --- |
| `data/plugin_data.db` | 随插件分发,只读基础数据为主 | `kungfu` 心法名称、别名和 JX3BOX 配装 ID | | AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 心法与别名、避雷记录、会话区服绑定、区服别名、事件订阅及基础数据缓存 |
| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 避雷记录、事件订阅、资历与交易行基础数据缓存 | | `data/kungfu.json` | 随插件分发,只在初始化时读取 | 32 条默认心法、别名和 JX3BOX 配装 ID |
| `data/server_aliases.json` | 随插件分发,只在初始化时读取 | 默认标准区服及其别名 |
`achievement_cache` 同时被资历基础数据和交易行物品分组复用。每个接口快照以一条 JSON 记录保存,当前使用 `achievement_menus``achievement_points``trade_item_groups` 三个键。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。资历菜单与点数的刷新接口分别为 JX3BOX Node 的 `/api/node/achievement/menus``/api/node/achievement/points` `achievement_cache` 同时被资历基础数据和交易行物品分组复用。每个接口快照以一条 JSON 记录保存,当前使用 `achievement_menus``achievement_points``trade_item_groups` 三个键。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。资历菜单与点数的刷新接口分别为 JX3BOX Node 的 `/api/node/achievement/menus``/api/node/achievement/points`
@@ -436,7 +454,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。 `core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。
每条消息按 `action` 识别事件,正文优先读取 `detail`,同时兼容 `data`。分发前查询 `event_push_subscriptions`:只有当前会话的 `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` 轮询状态表会被删除。
@@ -452,17 +470,22 @@ astrbot_plugin_jx3/
├── CHANGELOG.md # 版本更新记录 ├── CHANGELOG.md # 版本更新记录
├── LICENSE # GNU AGPL v3 ├── LICENSE # GNU AGPL v3
├── data/ ├── data/
── plugin_data.db # 随包心法/别名基础数据 ── kungfu.json # 心法别名与配装 ID 默认种子
│ └── server_aliases.json # 区服别名默认种子
├── core/ ├── core/
│ ├── jx3api_data.py # JX3API 业务服务 │ ├── jx3api_data.py # JX3API 业务服务
│ ├── jx3box_data.py # JX3BOX 业务服务与缓存逻辑 │ ├── jx3box_data.py # JX3BOX 业务服务与缓存逻辑
│ ├── message.py # 文本、图片、消息链和两轮会话构建 │ ├── message.py # 文本、图片、消息链和两轮会话构建
│ ├── request.py # aiohttp 请求封装 │ ├── request.py # aiohttp 请求封装
│ ├── event_push.py # WebSocket 事件通道与会话订阅 │ ├── event_push.py # WebSocket 事件通道与会话订阅
│ ├── server_binding.py # 会话区服绑定、别名与区服目录
│ ├── kungfu_alias.py # 本地心法目录初始化、校验与 WebUI 写入
│ ├── bilei_data.py # 避雷数据增删改查 │ ├── bilei_data.py # 避雷数据增删改查
│ ├── sqlite.py # aiosqlite 通用封装 │ ├── sqlite.py # aiosqlite 通用封装
│ ├── fun_basic.py # 图标、时间和货币格式化工具 │ ├── fun_basic.py # 图标、时间和货币格式化工具
│ └── template.py # 模板组合、异步读取与内存缓存 │ └── template.py # 模板组合、异步读取与内存缓存
├── pages/
│ └── server-management/ # AstrBot 会话管理 WebUI
└── templates/ └── templates/
├── layouts/ ├── layouts/
│ └── base.html # 唯一的完整 HTML 文档骨架 │ └── base.html # 唯一的完整 HTML 文档骨架
@@ -502,9 +525,9 @@ git diff --check
## 当前版本状态 ## 当前版本状态
以下内容是对 v3.3.1 当前源码的静态核对结果,部署和二次开发前应注意: 以下内容是对 v3.4.0 当前源码的静态核对结果,部署和二次开发前应注意:
1. `烟花``沙盘` 会通过 `serverdefault()` 补齐默认服务器,其他可选服务器参数仍会原样传为空字符串 1. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`
2. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus``/api/node/achievement/points` 刷新。 2. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus``/api/node/achievement/points` 刷新。
3. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。 3. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
4. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。 4. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
-6
View File
@@ -17,12 +17,6 @@
} }
} }
}, },
"server": {
"description": "默认服务器",
"type": "string",
"default": "梦江南",
"hint": "在指令中未输入服务器时,默认查询的服务器"
},
"jx3api_token": { "jx3api_token": {
"description": "JX3API Token", "description": "JX3API Token",
"type": "string", "type": "string",
+42 -4
View File
@@ -14,6 +14,7 @@ from astrbot.api.event import MessageChain
from astrbot.api.star import Context from astrbot.api.star import Context
from .sqlite import AsyncSQLiteDB from .sqlite import AsyncSQLiteDB
from .server_binding import ServerBindingService
DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" DEFAULT_WSS_URL = "wss://socket.nicemoe.cn"
@@ -70,10 +71,12 @@ class EventPushService:
context: Context, context: Context,
config: AstrBotConfig, config: AstrBotConfig,
sqlite: AsyncSQLiteDB, sqlite: AsyncSQLiteDB,
server_binding: ServerBindingService,
): ):
self.context = context self.context = context
self.config = config self.config = config
self.sql = sqlite self.sql = sqlite
self.server_binding = server_binding
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
@@ -252,6 +255,16 @@ class EventPushService:
return return
recipients = await self._enabled_sessions(action) recipients = await self._enabled_sessions(action)
if "server" in detail:
event_server = self.server_binding.resolve_server(detail.get("server"))
recipients = [
session_id
for session_id, bound_server in recipients
if not bound_server
or self.server_binding.resolve_server(bound_server) == event_server
]
else:
recipients = [session_id for session_id, _ in recipients]
if not recipients: if not recipients:
return return
@@ -271,13 +284,38 @@ class EventPushService:
message_chain = MessageChain().message(text) message_chain = MessageChain().message(text)
await self.context.send_message(session_id, message_chain) await self.context.send_message(session_id, message_chain)
async def _enabled_sessions(self, action: int) -> list[str]: async def _enabled_sessions(self, action: int) -> list[tuple[str, str]]:
column = self._action_column(action) column = self._action_column(action)
rows = await self.sql.fetch_all( rows = await self.sql.fetch_all(
f"SELECT session_id FROM event_push_subscriptions " f"SELECT subscriptions.session_id, "
f"WHERE enabled=1 AND {column}=1" f"COALESCE(bindings.server, '') AS server "
f"FROM event_push_subscriptions AS subscriptions "
f"LEFT JOIN session_server_bindings AS bindings "
f"ON bindings.session_id=subscriptions.session_id "
f"WHERE subscriptions.enabled=1 "
f"AND subscriptions.{column}=1"
) )
return [str(row["session_id"]) for row in rows] return [
(str(row["session_id"]), str(row.get("server") or "").strip())
for row in rows
]
async def list_subscription_statuses(self) -> list[dict[str, Any]]:
rows = await self.sql.fetch_all(
"SELECT * FROM event_push_subscriptions ORDER BY session_id"
)
return [
{
"session_id": str(row["session_id"]),
"enabled": row.get("enabled") == 1,
"actions": [
action
for action in EVENT_ACTIONS
if row.get(self._action_column(action)) == 1
],
}
for row in rows
]
async def configure( async def configure(
self, self,
+27 -2
View File
@@ -10,7 +10,7 @@ from astrbot.api import logger
from astrbot.api import AstrBotConfig from astrbot.api import AstrBotConfig
import astrbot.api.message_components as Comp import astrbot.api.message_components as Comp
from .request import APIClient from .request import APIClient, APIErrorResponse
from .sqlite import AsyncSQLiteDB from .sqlite import AsyncSQLiteDB
from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining
@@ -47,6 +47,22 @@ class JX3APIService:
if self._api: if self._api:
await self._api.close() await self._api.close()
async def server_list(self) -> list[str]:
"""获取当前有效区服名称,供会话绑定和参数消歧使用。"""
data = await self._base_request(
"/server/status/check",
{"server": "", "type": "其他"},
)
if not isinstance(data, list):
return []
return sorted(
{
str(item.get("server") or "").strip()
for item in data
if isinstance(item, dict) and item.get("server")
}
)
def _init_return_data(self) -> Dict[str, Any]: def _init_return_data(self) -> Dict[str, Any]:
"""初始化标准的返回数据结构""" """初始化标准的返回数据结构"""
@@ -75,7 +91,13 @@ class JX3APIService:
base_url = "https://www.jx3api.com" base_url = "https://www.jx3api.com"
api_url = base_url + api_path api_url = base_url + api_path
data = await self._api.get(api_url, params=params, out_key=out) data = await self._api.get(
api_url,
params=params,
out_key=out,
success_codes=(200, "200"),
return_error=True,
)
if not data: if not data:
logger.warning(f"获取接口信息失败或返回空数据: {api_url}") logger.warning(f"获取接口信息失败或返回空数据: {api_url}")
@@ -100,6 +122,9 @@ class JX3APIService:
return_data = self._init_return_data() return_data = self._init_return_data()
data = await self._base_request(path, params) data = await self._base_request(path, params)
if isinstance(data, APIErrorResponse):
return_data["msg"] = data.message or "获取接口信息失败"
return return_data
if data is None: if data is None:
return_data["msg"] = "获取接口信息失败" return_data["msg"] = "获取接口信息失败"
return return_data return return_data
+148
View File
@@ -0,0 +1,148 @@
import json
from pathlib import Path
from typing import Any, Iterable
from .sqlite import AsyncSQLiteDB
class KungfuAliasService:
"""维护本地心法名称、JX3BOX 配装 ID 和别名。"""
MAX_ALIASES = 5
def __init__(self, sqlite: AsyncSQLiteDB, seed_path: Path):
self.sql = sqlite
self.seed_path = seed_path
async def initialize(self):
await self.sql.execute(
"""
CREATE TABLE IF NOT EXISTS kungfu (
pzid INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name1 TEXT,
name2 TEXT,
name3 TEXT,
name4 TEXT,
name5 TEXT
)
"""
)
await self._seed_defaults()
async def _seed_defaults(self):
try:
records = json.loads(self.seed_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"读取心法种子数据失败:{exc}") from exc
if not isinstance(records, list):
raise RuntimeError("心法种子数据必须是数组")
for record in records:
if not isinstance(record, dict):
continue
pzid = self._parse_pzid(record.get("pzid"))
name = self._clean(record.get("name"))
aliases = self._normalize_aliases(name, record.get("aliases") or [])
values = [*aliases, *([None] * (self.MAX_ALIASES - len(aliases)))]
await self.sql.execute(
"""
INSERT OR IGNORE INTO kungfu
(pzid, name, name1, name2, name3, name4, name5)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(pzid, name, *values),
)
async def list_kungfu(self) -> list[dict[str, Any]]:
rows = await self.sql.fetch_all(
"SELECT pzid, name, name1, name2, name3, name4, name5 "
"FROM kungfu ORDER BY pzid"
)
return [
{
"pzid": int(row["pzid"]),
"name": self._clean(row.get("name")),
"aliases": [
alias
for key in ("name1", "name2", "name3", "name4", "name5")
if (alias := self._clean(row.get(key)))
],
}
for row in rows
]
async def save(self, pzid: Any, name: Any, aliases: Iterable[Any]):
normalized_pzid = self._parse_pzid(pzid)
normalized_name = self._clean(name)
if not normalized_name:
raise ValueError("标准心法名不能为空")
if len(normalized_name) > 64:
raise ValueError("标准心法名过长")
normalized_aliases = self._normalize_aliases(normalized_name, aliases)
occupied: dict[str, str] = {}
for row in await self.list_kungfu():
if row["pzid"] == normalized_pzid:
continue
for value in [row["name"], *row["aliases"]]:
occupied[self._key(value)] = row["name"]
for value in [normalized_name, *normalized_aliases]:
conflict = occupied.get(self._key(value))
if conflict:
raise ValueError(f"{value}”已被心法“{conflict}”使用")
values = [
*normalized_aliases,
*([None] * (self.MAX_ALIASES - len(normalized_aliases))),
]
await self.sql.execute(
"""
INSERT INTO kungfu (pzid, name, name1, name2, name3, name4, name5)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pzid) DO UPDATE SET
name=excluded.name,
name1=excluded.name1,
name2=excluded.name2,
name3=excluded.name3,
name4=excluded.name4,
name5=excluded.name5
""",
(normalized_pzid, normalized_name, *values),
)
def _normalize_aliases(self, name: str, aliases: Iterable[Any]) -> list[str]:
result: list[str] = []
seen: set[str] = {self._key(name)}
for value in aliases:
alias = self._clean(value)
key = self._key(alias)
if not alias or key in seen:
continue
if len(alias) > 64:
raise ValueError(f"心法别名过长:{alias}")
seen.add(key)
result.append(alias)
if len(result) > self.MAX_ALIASES:
raise ValueError(f"每个心法最多配置 {self.MAX_ALIASES} 个别名")
return result
@staticmethod
def _parse_pzid(value: Any) -> int:
try:
pzid = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("心法 ID 必须是整数") from exc
if pzid <= 0:
raise ValueError("心法 ID 必须大于 0")
return pzid
@staticmethod
def _clean(value: Any) -> str:
return str(value or "").strip()
@classmethod
def _key(cls, value: Any) -> str:
return cls._clean(value).casefold()
+17 -15
View File
@@ -38,14 +38,12 @@ class MessageBuilder:
) )
def __init__(self, def __init__(self,
server: str,
jx3api: JX3APIService, jx3api: JX3APIService,
jx3box: JX3BOXService, jx3box: JX3BOXService,
bilei: BiLeidata, bilei: BiLeidata,
event_push: EventPushService, event_push: EventPushService,
icons: dict[str, dict[str, str]] icons: dict[str, dict[str, str]]
): ):
self.server = server
self.jx3api = jx3api self.jx3api = jx3api
self.jx3box = jx3box self.jx3box = jx3box
self.bilei = bilei self.bilei = bilei
@@ -69,13 +67,6 @@ class MessageBuilder:
) )
def serverdefault(self,server) -> str:
"""加载配置默认服务器"""
if server == "":
return self.server
return server
async def plain_msg(self, event: AstrMessageEvent, action): async def plain_msg(self, event: AstrMessageEvent, action):
"""最终将数据整理成文本发送""" """最终将数据整理成文本发送"""
data= await action() data= await action()
@@ -89,7 +80,12 @@ class MessageBuilder:
await event.send(event.plain_result("猪脑过载,请稍后再试")) await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def T2I_image_msg(self, event: AstrMessageEvent, action): async def T2I_image_msg(
self,
event: AstrMessageEvent,
action,
render_options: dict | None = None,
):
"""最终将数据渲染成图片发送""" """最终将数据渲染成图片发送"""
data = await action() data = await action()
try: try:
@@ -101,6 +97,9 @@ class MessageBuilder:
"omit_background": False, "omit_background": False,
"type": "jpeg" "type": "jpeg"
} }
options.update(render_options or {})
if options.get("type") == "png":
options.pop("quality", None)
data["data"]["icons"] = self.icons data["data"]["icons"] = self.icons
url = await self.html_render(data["temp"], data["data"], options=options) url = await self.html_render(data["temp"], data["data"], options=options)
await event.send(event.image_result(url)) await event.send(event.image_result(url))
@@ -324,9 +323,9 @@ class MessageBuilder:
""" 阵营事件 阵营""" """ 阵营事件 阵营"""
return await self.T2I_image_msg(event, lambda: self.jx3api.zhenyingevent(name,50)) return await self.T2I_image_msg(event, lambda: self.jx3api.zhenyingevent(name,50))
async def yanhuachaxun(self, event: AstrMessageEvent,server: str = "",name: str = "" ): async def yanhuachaxun(self, event: AstrMessageEvent,server: str,name: str = "" ):
""" 烟花 服务器 角色""" """ 烟花 服务器 角色"""
return await self.T2I_image_msg(event, lambda: self.jx3api.yanhuachaxun( self.serverdefault(server),name)) return await self.T2I_image_msg(event, lambda: self.jx3api.yanhuachaxun(server,name))
async def shuma(self, event: AstrMessageEvent,server: str ): async def shuma(self, event: AstrMessageEvent,server: str ):
""" 刷马 服务器""" """ 刷马 服务器"""
@@ -448,10 +447,13 @@ class MessageBuilder:
""" 帮战 服务器""" """ 帮战 服务器"""
return await self.T2I_image_msg(event, lambda: self.jx3api.bangzhanjilu(server)) return await self.T2I_image_msg(event, lambda: self.jx3api.bangzhanjilu(server))
async def shapan(self, event: AstrMessageEvent,server: str = ""): async def shapan(self, event: AstrMessageEvent,server: str):
""" 沙盘 服务器""" """ 沙盘 服务器"""
server = self.serverdefault(server) return await self.T2I_image_msg(
return await self.T2I_image_msg(event, lambda: self.jx3api.shapan(server)) event,
lambda: self.jx3api.shapan(server),
render_options={"omit_background": True, "type": "png"},
)
async def zhueevent(self, event: AstrMessageEvent, server: str): async def zhueevent(self, event: AstrMessageEvent, server: str):
""" 诛恶事件 服务器""" """ 诛恶事件 服务器"""
+62 -15
View File
@@ -2,11 +2,20 @@
import json import json
import aiohttp import aiohttp
import asyncio import asyncio
from typing import Optional, Dict, Any, Union, List from dataclasses import dataclass
from typing import Optional, Dict, Any, Union, List, Collection
from aiohttp import ClientTimeout, ClientSession from aiohttp import ClientTimeout, ClientSession
from astrbot.api import logger from astrbot.api import logger
@dataclass(frozen=True, slots=True)
class APIErrorResponse:
"""保留接口业务错误,避免提取 data 时丢失 code/msg。"""
code: Any
message: str
class APIClient: class APIClient:
""" """
API客户端类 API客户端类
@@ -102,7 +111,7 @@ class APIClient:
return None return None
def _validate_api_payload(self, data: Any) -> Any: def _validate_api_payload(self, data: Any) -> Any:
"""校验业务层面的 JSON 数据结构""" """校验并规范化 JSON 数据结构"""
if not data: if not data:
logger.error("API返回空数据") logger.error("API返回空数据")
return None return None
@@ -114,32 +123,70 @@ class APIClient:
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
if isinstance(data, dict) and 'code' in data:
# 兼容多种成功状态码:200, "0", 0, 1
code = data.get('code')
if code not in [200, "0", 0, 1]:
msg = data.get('msg') or data.get('message', '未知错误')
logger.error(f"API业务报错: code={code}, msg={msg}")
return None
return data return data
async def get(self, url: str, params: Optional[Dict] = None, out_key: Optional[str] = None) -> Any: async def get(
self,
url: str,
params: Optional[Dict] = None,
out_key: Optional[str] = None,
success_codes: Optional[Collection[Any]] = None,
return_error: bool = False,
) -> Any:
"""GET 请求封装""" """GET 请求封装"""
data = await self._request('GET', url, params=params) data = await self._request('GET', url, params=params)
return self._extract_data(data, out_key) return self._extract_data(
data,
out_key,
success_codes=success_codes,
return_error=return_error,
)
async def post(self, url: str, data: Optional[Dict] = None, out_key: Optional[str] = None) -> Any: async def post(
self,
url: str,
data: Optional[Dict] = None,
out_key: Optional[str] = None,
success_codes: Optional[Collection[Any]] = None,
return_error: bool = False,
) -> Any:
"""POST 请求封装 (默认发送 JSON)""" """POST 请求封装 (默认发送 JSON)"""
data = await self._request('POST', url, json_data=data) data = await self._request('POST', url, json_data=data)
return self._extract_data(data, out_key) return self._extract_data(
data,
out_key,
success_codes=success_codes,
return_error=return_error,
)
def _extract_data(self, data: Any, key: Optional[str]) -> Any: def _extract_data(
self,
data: Any,
key: Optional[str],
success_codes: Optional[Collection[Any]] = None,
return_error: bool = False,
) -> Any:
"""辅助方法:从结果中提取指定字段""" """辅助方法:从结果中提取指定字段"""
if data is None: if data is None:
return None return None
if isinstance(data, bytes): if isinstance(data, bytes):
return data return data
if isinstance(data, dict) and 'code' in data:
allowed_codes = (
set(success_codes)
if success_codes is not None
else {200, "0", 0, 1}
)
code = data.get('code')
if code not in allowed_codes:
raw_message = data.get('msg') or data.get('message') or ""
message = str(raw_message).strip()
logger.error(
f"API业务报错: code={code}, msg={message or '未知错误'}"
)
if return_error:
return APIErrorResponse(code=code, message=message)
return None
if key and isinstance(data, dict): if key and isinstance(data, dict):
return data.get(key, {}) return data.get(key, {})
return data return data
+241
View File
@@ -0,0 +1,241 @@
import json
from pathlib import Path
from typing import Any, Iterable
from .sqlite import AsyncSQLiteDB
class ServerBindingService:
"""维护会话区服绑定、区服别名与指令解析所需的区服目录。"""
def __init__(self, sqlite: AsyncSQLiteDB, seed_path: Path):
self.sql = sqlite
self.seed_path = seed_path
self._remote_servers: set[str] = set()
self._known_servers: set[str] = set()
self._server_lookup: dict[str, str] = {}
async def initialize(self):
await self.sql.execute(
"""
CREATE TABLE IF NOT EXISTS session_server_bindings (
session_id TEXT PRIMARY KEY,
server TEXT NOT NULL
)
"""
)
await self.sql.execute(
"""
CREATE TABLE IF NOT EXISTS server_aliases (
server TEXT PRIMARY KEY,
aliases TEXT NOT NULL DEFAULT '[]'
)
"""
)
await self._seed_aliases()
await self._reload_cache()
async def _seed_aliases(self):
try:
records = json.loads(self.seed_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"读取区服别名种子数据失败:{exc}") from exc
if not isinstance(records, list):
raise RuntimeError("区服别名种子数据必须是数组")
for record in records:
if not isinstance(record, dict):
continue
server = self._clean(record.get("server"))
raw_aliases = record.get("aliases") or []
if not server or not isinstance(raw_aliases, list):
continue
aliases = []
seen: set[str] = set()
for value in raw_aliases:
alias = self._clean(value)
key = self._key(alias)
if alias and key != self._key(server) and key not in seen:
seen.add(key)
aliases.append(alias)
await self.sql.execute(
"""
INSERT OR IGNORE INTO server_aliases (server, aliases)
VALUES (?, ?)
""",
(server, json.dumps(aliases, ensure_ascii=False)),
)
async def _reload_cache(self):
bindings = await self.list_bindings()
alias_rows = await self.list_aliases()
known = set(self._remote_servers)
known.update(row["server"] for row in bindings)
known.update(row["server"] for row in alias_rows)
lookup = {self._key(server): server for server in known}
for row in alias_rows:
server = row["server"]
lookup.setdefault(self._key(server), server)
for alias in row["aliases"]:
# 新增的官方区服名优先于历史别名,避免目录更新后误解析。
lookup.setdefault(self._key(alias), server)
self._known_servers = known
self._server_lookup = lookup
async def update_server_catalog(self, servers: Iterable[str]):
self._remote_servers = {
normalized
for value in servers
if (normalized := self._clean(value))
}
await self._reload_cache()
def known_servers(self) -> list[str]:
return sorted(self._known_servers)
def is_known_server(self, value: Any) -> bool:
return self._key(value) in self._server_lookup
def resolve_server(self, value: Any) -> str:
server = self._clean(value)
return self._server_lookup.get(self._key(server), server)
async def get_binding(self, session_id: str) -> str:
row = await self.sql.select_one(
"session_server_bindings",
"session_id=?",
(session_id,),
)
return self._clean(row.get("server")) if row else ""
async def set_binding(self, session_id: str, server: str):
session_id = self._clean(session_id)
server = self.resolve_server(server)
if not session_id:
raise ValueError("会话 ID 不能为空")
if not server:
raise ValueError("绑定区服不能为空")
if len(session_id) > 512 or len(server) > 64:
raise ValueError("会话 ID 或区服名称过长")
await self.sql.execute(
"""
INSERT INTO session_server_bindings (session_id, server)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET server=excluded.server
""",
(session_id, server),
)
await self._reload_cache()
async def delete_binding(self, session_id: str):
await self.sql.delete(
"session_server_bindings",
"session_id=?",
(self._clean(session_id),),
)
await self._reload_cache()
async def list_bindings(self) -> list[dict[str, str]]:
rows = await self.sql.fetch_all(
"SELECT session_id, server FROM session_server_bindings "
"ORDER BY session_id"
)
return [
{
"session_id": self._clean(row.get("session_id")),
"server": self._clean(row.get("server")),
}
for row in rows
]
async def set_aliases(self, server: str, aliases: Iterable[str]):
server = self._clean(server)
if not server:
raise ValueError("标准区服名不能为空")
if len(server) > 64:
raise ValueError("区服名称过长")
cleaned_aliases: list[str] = []
seen: set[str] = set()
for value in aliases:
alias = self._clean(value)
key = self._key(alias)
if not alias or key == self._key(server) or key in seen:
continue
if len(alias) > 64:
raise ValueError(f"区服别名过长:{alias}")
seen.add(key)
cleaned_aliases.append(alias)
if len(cleaned_aliases) > 50:
raise ValueError("每个区服最多配置 50 个别名")
alias_rows = await self.list_aliases()
occupied: dict[str, str] = {
self._key(known_server): known_server
for known_server in self._known_servers
if self._key(known_server) != self._key(server)
}
for row in alias_rows:
existing_server = row["server"]
if self._key(existing_server) == self._key(server):
continue
occupied[self._key(existing_server)] = existing_server
for alias in row["aliases"]:
occupied[self._key(alias)] = existing_server
for value in [server, *cleaned_aliases]:
conflict = occupied.get(self._key(value))
if conflict:
raise ValueError(f"{value}”已被区服“{conflict}”使用")
await self.sql.execute(
"""
INSERT INTO server_aliases (server, aliases)
VALUES (?, ?)
ON CONFLICT(server) DO UPDATE SET aliases=excluded.aliases
""",
(server, json.dumps(cleaned_aliases, ensure_ascii=False)),
)
await self._reload_cache()
async def delete_aliases(self, server: str):
await self.sql.delete(
"server_aliases",
"server=?",
(self._clean(server),),
)
await self._reload_cache()
async def list_aliases(self) -> list[dict[str, Any]]:
rows = await self.sql.fetch_all(
"SELECT server, aliases FROM server_aliases ORDER BY server"
)
result = []
for row in rows:
aliases: list[str] = []
try:
raw_aliases = json.loads(row.get("aliases") or "[]")
if isinstance(raw_aliases, list):
aliases = [
self._clean(value)
for value in raw_aliases
if self._clean(value)
]
except (TypeError, json.JSONDecodeError):
aliases = []
result.append(
{"server": self._clean(row.get("server")), "aliases": aliases}
)
return result
@staticmethod
def _clean(value: Any) -> str:
return str(value or "").strip()
@classmethod
def _key(cls, value: Any) -> str:
return cls._clean(value).casefold()
+34
View File
@@ -0,0 +1,34 @@
[
{"pzid": 10002, "name": "洗髓经", "aliases": ["秃T", "和尚T"]},
{"pzid": 10003, "name": "易筋经", "aliases": ["少林", "和尚"]},
{"pzid": 10014, "name": "紫霞功", "aliases": ["气纯"]},
{"pzid": 10015, "name": "太虚剑意", "aliases": ["剑纯"]},
{"pzid": 10021, "name": "花间游", "aliases": ["花间", "万花"]},
{"pzid": 10026, "name": "傲血战意", "aliases": ["天策", "马男"]},
{"pzid": 10028, "name": "离经易道", "aliases": ["奶花"]},
{"pzid": 10062, "name": "铁牢律", "aliases": ["策T"]},
{"pzid": 10080, "name": "云裳心经", "aliases": ["奶秀"]},
{"pzid": 10081, "name": "冰心诀", "aliases": ["冰心", "七秀"]},
{"pzid": 10144, "name": "问水诀", "aliases": ["藏剑", "黄鸡"]},
{"pzid": 10175, "name": "毒经", "aliases": []},
{"pzid": 10176, "name": "补天诀", "aliases": ["奶毒"]},
{"pzid": 10224, "name": "惊羽诀", "aliases": ["鲸鱼"]},
{"pzid": 10225, "name": "天罗诡道", "aliases": ["田螺"]},
{"pzid": 10242, "name": "焚影圣诀", "aliases": ["明教"]},
{"pzid": 10243, "name": "明尊琉璃体", "aliases": ["喵T"]},
{"pzid": 10268, "name": "笑尘诀", "aliases": ["丐帮"]},
{"pzid": 10389, "name": "铁骨衣", "aliases": ["苍T"]},
{"pzid": 10390, "name": "分山劲", "aliases": ["苍云"]},
{"pzid": 10447, "name": "莫问", "aliases": []},
{"pzid": 10448, "name": "相知", "aliases": ["奶歌"]},
{"pzid": 10464, "name": "北傲诀", "aliases": ["霸刀"]},
{"pzid": 10533, "name": "凌海诀", "aliases": ["蓬莱"]},
{"pzid": 10585, "name": "隐龙诀", "aliases": ["凌雪"]},
{"pzid": 10615, "name": "太玄经", "aliases": ["衍天"]},
{"pzid": 10626, "name": "灵素", "aliases": ["奶药"]},
{"pzid": 10627, "name": "无方", "aliases": []},
{"pzid": 10698, "name": "孤锋诀", "aliases": ["刀宗"]},
{"pzid": 10756, "name": "山海心诀", "aliases": ["万灵"]},
{"pzid": 10786, "name": "周天功", "aliases": ["段式"]},
{"pzid": 10821, "name": "幽罗引", "aliases": ["无相楼"]}
]
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
[
{"server": "梦江南", "aliases": ["双梦"]},
{"server": "唯我独尊", "aliases": ["唯满侠"]},
{"server": "乾坤一掷", "aliases": ["华乾"]},
{"server": "剑胆琴心", "aliases": ["煎蛋"]},
{"server": "斗转星移", "aliases": ["姨妈"]}
]
+270 -29
View File
@@ -1,24 +1,32 @@
import inspect import inspect
import re
from pathlib import Path from pathlib import Path
from sys import maxsize
from typing import cast from typing import cast
from astrbot.api.event import filter, AstrMessageEvent 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 EventPushService from .core.event_push import EVENT_NAMES, EventPushService
from .core.bilei_data import BiLeidata from .core.bilei_data import BiLeidata
from .core.kungfu_alias import KungfuAliasService
from .core.server_binding import ServerBindingService
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
PLUGIN_NAME = "astrbot_plugin_jx3"
@register("astrbot_plugin_jx3", @register("astrbot_plugin_jx3",
"fxdyz", "fxdyz",
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。", "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
"3.3.1", "3.4.0",
"https://github.com/qsc20001102/astrbot_plugin_jx3" "https://github.com/qsc20001102/astrbot_plugin_jx3"
) )
class Jx3ApiPlugin(Star): class Jx3ApiPlugin(Star):
@@ -34,16 +42,14 @@ class Jx3ApiPlugin(Star):
else: else:
logger.info(f"未启用指令前缀功能。") logger.info(f"未启用指令前缀功能。")
# 默认服务器
self.server = self.conf.get("server","梦江南")
logger.info(f"配置加载默认服务器:{self.server}")
# 获取数据文件路径 # 获取数据文件路径
self.get_data_path() self.get_data_path()
# 加载图片base64编码 # 加载图片base64编码
self.load_local_base64() self.load_local_base64()
# 构造所有类 # 构造所有类
self.create_all() self.create_all()
# 注册插件管理页接口
self._register_web_apis(context)
# 声明指令集 # 声明指令集
@@ -58,9 +64,13 @@ class Jx3ApiPlugin(Star):
# 数据库初始化 # 数据库初始化
await self.init_bilei_data() await self.init_bilei_data()
await self.init_achievement_cache_data() await self.init_achievement_cache_data()
await self.kungfu_alias.initialize()
await self.server_binding.initialize()
# 连接插件数据 # 获取区服目录,用于识别完整参数与区服别名。
await self.plugin_sql_db.connect() await self.server_binding.update_server_catalog(
await self.jx3api.server_list()
)
# 开启实时事件通道 # 开启实时事件通道
await self.event_push.initialize() await self.event_push.initialize()
@@ -92,9 +102,6 @@ class Jx3ApiPlugin(Star):
if self.local_sql_db: if self.local_sql_db:
await self.local_sql_db.close() await self.local_sql_db.close()
if self.plugin_sql_db:
await self.plugin_sql_db.close()
logger.info("jx3api插件已卸载/停用") logger.info("jx3api插件已卸载/停用")
@@ -108,8 +115,8 @@ class Jx3ApiPlugin(Star):
# SQLite本地路径 # SQLite本地路径
self.local_data_path = self.local_data_dir / "local_data.db" self.local_data_path = self.local_data_dir / "local_data.db"
# SQLite插件路径 self.kungfu_seed_path = self.plugin_data_dir / "kungfu.json"
self.plugin_data_path = self.plugin_data_dir /"plugin_data.db" self.server_alias_seed_path = self.plugin_data_dir / "server_aliases.json"
# 图片文件路径 # 图片文件路径
self.plugin_temp_img = self.plugin_temp_dir / "img" self.plugin_temp_img = self.plugin_temp_dir / "img"
@@ -119,7 +126,8 @@ class Jx3ApiPlugin(Star):
# 数据路径打印 # 数据路径打印
logger.debug(f"本地数据路径: {self.local_data_path}") logger.debug(f"本地数据路径: {self.local_data_path}")
logger.debug(f"插件数据路径: {self.plugin_data_path}") logger.debug(f"心法种子数据路径: {self.kungfu_seed_path}")
logger.debug(f"区服别名种子数据路径: {self.server_alias_seed_path}")
logger.debug(f"图片文件路径: {self.plugin_temp_img}") logger.debug(f"图片文件路径: {self.plugin_temp_img}")
logger.debug(f"沙盘图片文件路径: {self.plugin_temp_sand}") logger.debug(f"沙盘图片文件路径: {self.plugin_temp_sand}")
logger.debug(f"图片文件路径: {self.plugin_temp_sect}") logger.debug(f"图片文件路径: {self.plugin_temp_sect}")
@@ -145,18 +153,25 @@ class Jx3ApiPlugin(Star):
"""构造所有类""" """构造所有类"""
# 数据库实例化 # 数据库实例化
self.local_sql_db = AsyncSQLiteDB(str(self.local_data_path)) self.local_sql_db = AsyncSQLiteDB(str(self.local_data_path))
self.plugin_sql_db = AsyncSQLiteDB(str(self.plugin_data_path))
# 剑网三功能实例化 # 剑网三功能实例化
self.bilei = BiLeidata(self.local_sql_db) self.bilei = BiLeidata(self.local_sql_db)
self.jx3api = JX3APIService(self.conf, self.plugin_sql_db, self.local_sql_db) self.jx3api = JX3APIService(self.conf, self.local_sql_db, self.local_sql_db)
self.jx3box = JX3BOXService(self.conf, self.plugin_sql_db, self.local_sql_db) self.jx3box = JX3BOXService(self.conf, self.local_sql_db, self.local_sql_db)
self.kungfu_alias = KungfuAliasService(
self.local_sql_db,
self.kungfu_seed_path,
)
self.server_binding = ServerBindingService(
self.local_sql_db,
self.server_alias_seed_path,
)
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.jx3cmd = MessageBuilder( self.jx3cmd = MessageBuilder(
self.server,
self.jx3api, self.jx3api,
self.jx3box, self.jx3box,
self.bilei, self.bilei,
@@ -296,6 +311,8 @@ class Jx3ApiPlugin(Star):
"资历": self. jx3cmd.zili, "资历": self. jx3cmd.zili,
"交易行": self. jx3cmd.jiaoyihang, "交易行": self. jx3cmd.jiaoyihang,
"绑定区服": self.bind_server,
"解绑区服": self.unbind_server,
"事件推送": self.jx3cmd.shijian_tuisong, "事件推送": self.jx3cmd.shijian_tuisong,
"避雷添加": self.jx3cmd.bilei_add, "避雷添加": self.jx3cmd.bilei_add,
"避雷查看": self.jx3cmd.bilei_all, "避雷查看": self.jx3cmd.bilei_all,
@@ -313,7 +330,9 @@ class Jx3ApiPlugin(Star):
# 前缀模式 # 前缀模式
if self.prefix.get("enable"): if self.prefix.get("enable"):
prefix = self.prefix.get("text") prefix = str(self.prefix.get("text") or "").strip()
if not prefix:
return None
if text.startswith(prefix): if text.startswith(prefix):
text = text[len(prefix):].strip() text = text[len(prefix):].strip()
else: else:
@@ -322,6 +341,90 @@ class Jx3ApiPlugin(Star):
return text.split() return text.split()
def resolve_command(self, event: AstrMessageEvent):
"""从 AstrBot 处理后文本和平台原始文本中识别本插件指令。"""
processed_text = event.message_str
original_text = getattr(event.message_obj, "message_str", "")
seen = set()
for text in (processed_text, original_text):
if not isinstance(text, str):
continue
text = text.strip()
if not text or text in seen:
continue
seen.add(text)
parts = self.parse_message(text)
if not parts:
continue
cmd, *args = parts
handler = self.command_map.get(cmd)
if handler:
return cmd, args, handler
return None
async def _prepare_server_args(
self,
handler,
event: AstrMessageEvent,
args: list[str],
) -> list[str]:
"""为带 server 参数的指令补齐会话绑定,并解析区服别名。"""
params = [
parameter
for parameter in inspect.signature(handler).parameters.values()
if parameter.name not in {"self", "event"}
]
server_index = next(
(index for index, parameter in enumerate(params) if parameter.name == "server"),
None,
)
if server_index is None:
return args
prepared = list(args)
bound_server = await self.server_binding.get_binding(
event.unified_msg_origin
)
has_server_arg = server_index < len(prepared)
if bound_server:
required_count = sum(
parameter.default is inspect.Parameter.empty
for parameter in params
)
has_required_parameter_after_server = any(
parameter.default is inspect.Parameter.empty
for parameter in params[server_index + 1:]
)
explicit_server = (
has_server_arg
and (
self.server_binding.is_known_server(prepared[server_index])
or len(prepared) >= len(params)
or (
has_required_parameter_after_server
and len(prepared) >= required_count
)
)
)
if explicit_server:
prepared[server_index] = self.server_binding.resolve_server(
prepared[server_index]
)
else:
prepared.insert(server_index, bound_server)
elif has_server_arg:
prepared[server_index] = self.server_binding.resolve_server(
prepared[server_index]
)
return prepared
async def _call_with_auto_args(self, handler, event: AstrMessageEvent, args: list[str]): async def _call_with_auto_args(self, handler, event: AstrMessageEvent, args: list[str]):
"""指令执行函数""" """指令执行函数"""
@@ -361,27 +464,29 @@ class Jx3ApiPlugin(Star):
return await handler(*call_args) return await handler(*call_args)
@filter.event_message_type(filter.EventMessageType.ALL) @filter.event_message_type(
filter.EventMessageType.ALL,
priority=maxsize - 10,
)
async def on_all_message(self, event: AstrMessageEvent): async def on_all_message(self, event: AstrMessageEvent):
"""解析所有消息""" """解析所有消息"""
if not self.command_map: if not self.command_map:
logger.debug("插件尚未初始化完成,忽略消息") logger.debug("插件尚未初始化完成,忽略消息")
return return
# 获取消息 command = self.resolve_command(event)
parts = self.parse_message(event.message_str) if not command:
if not parts:
logger.debug("未触发指令,忽略消息") logger.debug("未触发指令,忽略消息")
return return
cmd, *args = parts cmd, args, handler = command
handler = self.command_map.get(cmd)
if not handler: # 一旦确认是本插件指令,立即阻止后续插件和默认 LLM 继续处理。
logger.debug("指令函数为空,忽略消息") event.stop_event()
return event.should_call_llm(True)
try: try:
event.stop_event() 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)
if ret is not None: if ret is not None:
yield ret yield ret
@@ -389,6 +494,142 @@ class Jx3ApiPlugin(Star):
logger.exception(f"指令执行失败: {cmd}, error={e}") logger.exception(f"指令执行失败: {cmd}, error={e}")
yield event.plain_result("参数错误或执行失败") yield event.plain_result("参数错误或执行失败")
async def bind_server(
self,
event: AstrMessageEvent,
server_name: str = "",
):
"""查看或设置当前会话绑定区服。"""
session_id = event.unified_msg_origin
if not server_name.strip():
bound_server = await self.server_binding.get_binding(session_id)
text = (
f"当前会话绑定区服:{bound_server}"
if bound_server
else "当前会话尚未绑定区服。\n用法:绑定区服 区服名"
)
else:
server = self.server_binding.resolve_server(server_name)
await self.server_binding.set_binding(session_id, server)
text = f"当前会话已绑定区服:{server}"
await event.send(event.plain_result(text))
async def unbind_server(self, event: AstrMessageEvent):
"""解除当前会话的区服绑定。"""
await self.server_binding.delete_binding(event.unified_msg_origin)
await event.send(event.plain_result("当前会话已解除区服绑定。"))
def _register_web_apis(self, context: Context):
routes = (
("dashboard", self.page_dashboard, ["GET"], "读取会话管理数据"),
("bindings/save", self.page_save_binding, ["POST"], "保存会话区服绑定"),
("bindings/delete", self.page_delete_binding, ["POST"], "删除会话区服绑定"),
("aliases/save", self.page_save_aliases, ["POST"], "保存区服别名"),
("aliases/delete", self.page_delete_aliases, ["POST"], "删除区服别名"),
("kungfu/save", self.page_save_kungfu, ["POST"], "保存心法别名"),
("servers/refresh", self.page_refresh_servers, ["POST"], "刷新区服目录"),
)
for path, handler, methods, description in routes:
context.register_web_api(
f"/{PLUGIN_NAME}/{path}",
handler,
methods,
description,
)
async def page_dashboard(self):
bindings = await self.server_binding.list_bindings()
subscriptions = await self.event_push.list_subscription_statuses()
aliases = await self.server_binding.list_aliases()
kungfu = await self.kungfu_alias.list_kungfu()
return json_response(
{
"bindings": bindings,
"subscriptions": subscriptions,
"aliases": aliases,
"kungfu": kungfu,
"servers": self.server_binding.known_servers(),
"events": {str(action): name for action, name in EVENT_NAMES.items()},
}
)
async def page_save_binding(self):
payload = await request.json(default={})
if not isinstance(payload, dict):
return error_response("请求正文必须是 JSON 对象", status_code=400)
try:
await self.server_binding.set_binding(
str(payload.get("session_id") or ""),
str(payload.get("server") or ""),
)
except ValueError as exc:
return error_response(str(exc), status_code=400)
return json_response({"saved": True})
async def page_delete_binding(self):
payload = await request.json(default={})
session_id = str(payload.get("session_id") or "") if isinstance(payload, dict) else ""
if not session_id.strip():
return error_response("会话 ID 不能为空", status_code=400)
await self.server_binding.delete_binding(session_id)
return json_response({"deleted": True})
async def page_save_aliases(self):
payload = await request.json(default={})
if not isinstance(payload, dict):
return error_response("请求正文必须是 JSON 对象", status_code=400)
raw_aliases = payload.get("aliases", [])
if isinstance(raw_aliases, str):
aliases = re.split(r"[,;\n]+", raw_aliases)
elif isinstance(raw_aliases, list):
aliases = [str(value) for value in raw_aliases]
else:
return error_response("别名必须是字符串或数组", status_code=400)
try:
await self.server_binding.set_aliases(
str(payload.get("server") or ""),
aliases,
)
except ValueError as exc:
return error_response(str(exc), status_code=400)
return json_response({"saved": True})
async def page_delete_aliases(self):
payload = await request.json(default={})
server = str(payload.get("server") or "") if isinstance(payload, dict) else ""
if not server.strip():
return error_response("标准区服名不能为空", status_code=400)
await self.server_binding.delete_aliases(server)
return json_response({"deleted": True})
async def page_save_kungfu(self):
payload = await request.json(default={})
if not isinstance(payload, dict):
return error_response("请求正文必须是 JSON 对象", status_code=400)
raw_aliases = payload.get("aliases", [])
if isinstance(raw_aliases, str):
aliases = re.split(r"[,;\n]+", raw_aliases)
elif isinstance(raw_aliases, list):
aliases = [str(value) for value in raw_aliases]
else:
return error_response("别名必须是字符串或数组", status_code=400)
try:
await self.kungfu_alias.save(
payload.get("pzid"),
payload.get("name"),
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()})
+2 -2
View File
@@ -1,10 +1,10 @@
name: astrbot_plugin_jx3 name: astrbot_plugin_jx3
display_name: 剑网三游戏数据查询工具 display_name: 剑网三游戏数据查询工具
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。 desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
version: 3.3.1 version: 3.4.0
author: 飞翔大野猪 author: 飞翔大野猪
repo: https://github.com/qsc20001102/astrbot_plugin_jx3 repo: https://github.com/qsc20001102/astrbot_plugin_jx3
astrbot_version: ">=4.11.0" astrbot_version: ">=4.24.1"
support_platforms: support_platforms:
- aiocqhttp - aiocqhttp
- weixin_oc - weixin_oc
+282
View File
@@ -0,0 +1,282 @@
const bridge = window.AstrBotPluginPage;
const state = { bindings: [], subscriptions: [], aliases: [], kungfu: [], servers: [], events: {} };
let toastTimer;
const byId = (id) => document.getElementById(id);
function showToast(message, isError = false) {
const toast = byId("toast");
toast.textContent = message;
toast.classList.toggle("is-error", isError);
toast.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove("is-visible"), 2600);
}
function button(label, className, onClick) {
const element = document.createElement("button");
element.type = "button";
element.className = `link-button ${className || ""}`.trim();
element.textContent = label;
element.addEventListener("click", onClick);
return element;
}
function emptyRow(columnCount, message) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = columnCount;
cell.className = "empty";
cell.textContent = message;
row.append(cell);
return row;
}
function bindingMap() {
return new Map(state.bindings.map((item) => [item.session_id, item.server]));
}
function renderSummary() {
byId("binding-count").textContent = String(state.bindings.length);
byId("subscription-count").textContent = String(state.subscriptions.filter((item) => item.enabled).length);
byId("alias-count").textContent = String(state.aliases.reduce((total, item) => total + item.aliases.length, 0));
byId("kungfu-count").textContent = String(state.kungfu.length);
}
function renderServerOptions() {
const list = byId("server-options");
list.replaceChildren(...state.servers.map((server) => {
const option = document.createElement("option");
option.value = server;
return option;
}));
}
function renderBindings() {
const body = byId("bindings-body");
if (!state.bindings.length) {
body.replaceChildren(emptyRow(3, "暂无会话绑定"));
return;
}
body.replaceChildren(...state.bindings.map((item) => {
const row = document.createElement("tr");
const session = document.createElement("td");
const server = document.createElement("td");
const actions = document.createElement("td");
session.dataset.label = "会话 ID";
server.dataset.label = "绑定区服";
actions.dataset.label = "操作";
session.textContent = item.session_id;
server.textContent = item.server;
actions.className = "actions";
actions.append(
button("编辑", "", () => {
byId("binding-session").value = item.session_id;
byId("binding-server").value = item.server;
byId("binding-server").focus();
}),
button("解除绑定", "link-button--danger", async () => {
if (!window.confirm(`确认解除会话 ${item.session_id} 的区服绑定?`)) return;
await mutate("bindings/delete", { session_id: item.session_id }, "绑定已解除");
}),
);
row.append(session, server, actions);
return row;
}));
}
function renderSubscriptions() {
const body = byId("subscriptions-body");
const bindings = bindingMap();
if (!state.subscriptions.length) {
body.replaceChildren(emptyRow(4, "暂无事件订阅会话"));
return;
}
body.replaceChildren(...state.subscriptions.map((item) => {
const row = document.createElement("tr");
const session = document.createElement("td");
const server = document.createElement("td");
const enabled = document.createElement("td");
const actions = document.createElement("td");
session.dataset.label = "会话 ID";
server.dataset.label = "绑定区服";
enabled.dataset.label = "总开关";
actions.dataset.label = "已订阅事件";
session.textContent = item.session_id;
server.textContent = bindings.get(item.session_id) || "未绑定(全部区服)";
const stateLabel = document.createElement("span");
stateLabel.className = `state ${item.enabled ? "state--on" : "state--off"}`;
stateLabel.textContent = item.enabled ? "开启" : "关闭";
enabled.append(stateLabel);
const tags = document.createElement("div");
tags.className = "tags";
if (item.actions.length) {
item.actions.forEach((action) => {
const tag = document.createElement("span");
tag.className = "tag";
tag.textContent = `${action} ${state.events[String(action)] || "未知事件"}`;
tags.append(tag);
});
} else {
tags.textContent = "无";
}
actions.append(tags);
row.append(session, server, enabled, actions);
return row;
}));
}
function renderAliases() {
const body = byId("aliases-body");
if (!state.aliases.length) {
body.replaceChildren(emptyRow(3, "暂无区服别名配置"));
return;
}
body.replaceChildren(...state.aliases.map((item) => {
const row = document.createElement("tr");
const server = document.createElement("td");
const aliases = document.createElement("td");
const actions = document.createElement("td");
server.dataset.label = "标准区服名";
aliases.dataset.label = "别名";
actions.dataset.label = "操作";
server.textContent = item.server;
aliases.textContent = item.aliases.length ? item.aliases.join("、") : "无";
actions.className = "actions";
actions.append(
button("编辑", "", () => {
byId("alias-server").value = item.server;
byId("alias-values").value = item.aliases.join(", ");
byId("alias-values").focus();
}),
button("删除", "link-button--danger", async () => {
if (!window.confirm(`确认删除 ${item.server} 的全部别名?`)) return;
await mutate("aliases/delete", { server: item.server }, "别名已删除");
}),
);
row.append(server, aliases, actions);
return row;
}));
}
function renderKungfu() {
const body = byId("kungfu-body");
if (!state.kungfu.length) {
body.replaceChildren(emptyRow(4, "暂无心法配置"));
return;
}
body.replaceChildren(...state.kungfu.map((item) => {
const row = document.createElement("tr");
const pzid = document.createElement("td");
const name = document.createElement("td");
const aliases = document.createElement("td");
const actions = document.createElement("td");
pzid.dataset.label = "心法 ID";
name.dataset.label = "标准心法名";
aliases.dataset.label = "别名";
actions.dataset.label = "操作";
pzid.textContent = String(item.pzid);
name.textContent = item.name;
aliases.textContent = item.aliases.length ? item.aliases.join("、") : "无";
actions.className = "actions";
actions.append(button("编辑", "", () => {
byId("kungfu-pzid").value = String(item.pzid);
byId("kungfu-name").value = item.name;
byId("kungfu-aliases").value = item.aliases.join(", ");
byId("kungfu-aliases").focus();
}));
row.append(pzid, name, aliases, actions);
return row;
}));
}
function render() {
renderSummary();
renderServerOptions();
renderBindings();
renderSubscriptions();
renderAliases();
renderKungfu();
}
async function loadData() {
const data = await bridge.apiGet("dashboard");
Object.assign(state, data);
render();
}
async function mutate(endpoint, payload, successMessage) {
try {
await bridge.apiPost(endpoint, payload);
await loadData();
showToast(successMessage);
return true;
} catch (error) {
showToast(error?.message || "操作失败", true);
return false;
}
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((item) => {
const active = item === tab;
item.classList.toggle("is-active", active);
item.setAttribute("aria-selected", String(active));
});
document.querySelectorAll(".panel").forEach((panel) => {
const active = panel.id === `${tab.dataset.tab}-panel`;
panel.classList.toggle("is-active", active);
panel.hidden = !active;
});
});
});
byId("binding-form").addEventListener("submit", async (event) => {
event.preventDefault();
const saved = await mutate("bindings/save", {
session_id: byId("binding-session").value,
server: byId("binding-server").value,
}, "绑定信息已保存");
if (saved) event.currentTarget.reset();
});
byId("alias-form").addEventListener("submit", async (event) => {
event.preventDefault();
const saved = await mutate("aliases/save", {
server: byId("alias-server").value,
aliases: byId("alias-values").value,
}, "区服别名已保存");
if (saved) event.currentTarget.reset();
});
byId("kungfu-form").addEventListener("submit", async (event) => {
event.preventDefault();
const saved = await mutate("kungfu/save", {
pzid: byId("kungfu-pzid").value,
name: byId("kungfu-name").value,
aliases: byId("kungfu-aliases").value,
}, "心法配置已保存");
if (saved) event.currentTarget.reset();
});
byId("refresh").addEventListener("click", async (event) => {
const control = event.currentTarget;
control.disabled = true;
try {
await bridge.apiPost("servers/refresh", {});
await loadData();
showToast("区服目录与页面数据已刷新");
} catch (error) {
showToast(error?.message || "刷新失败", true);
} finally {
control.disabled = false;
}
});
await bridge.ready();
try {
await loadData();
} catch (error) {
showToast(error?.message || "管理数据加载失败", true);
}
+127
View File
@@ -0,0 +1,127 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>剑网三插件管理</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main class="shell">
<header class="page-header">
<div>
<h1>剑网三插件管理</h1>
<p>管理会话区服绑定、事件订阅状态、区服别名与心法别名。</p>
</div>
<button class="button button--secondary" id="refresh" type="button">刷新数据</button>
</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">
<button class="tab is-active" type="button" role="tab" aria-selected="true" data-tab="bindings">会话绑定</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="aliases">区服别名</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="kungfu">心法别名</button>
</nav>
<section class="panel is-active" id="bindings-panel" role="tabpanel">
<form class="editor" id="binding-form">
<label>
<span>会话 ID</span>
<input id="binding-session" name="session_id" maxlength="512" placeholder="例如:QQ:GroupMessage:123456" required />
</label>
<label>
<span>绑定区服</span>
<input id="binding-server" name="server" maxlength="64" list="server-options" placeholder="选择或输入标准区服名/别名" required />
</label>
<button class="button button--primary" type="submit">保存绑定</button>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>会话 ID</th><th>绑定区服</th><th class="actions">操作</th></tr></thead>
<tbody id="bindings-body"></tbody>
</table>
</div>
</section>
<section class="panel" id="subscriptions-panel" role="tabpanel" hidden>
<div class="section-intro">
<div><h2>所有会话事件状态</h2><p>总开关和已订阅事件均来自本地订阅表。</p></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>会话 ID</th><th>绑定区服</th><th>总开关</th><th>已订阅事件</th></tr></thead>
<tbody id="subscriptions-body"></tbody>
</table>
</div>
</section>
<section class="panel" id="aliases-panel" role="tabpanel" hidden>
<form class="editor editor--aliases" id="alias-form">
<label>
<span>标准区服名</span>
<input id="alias-server" name="server" maxlength="64" list="server-options" placeholder="例如:梦江南" required />
</label>
<label>
<span>别名(多个别名用逗号分隔)</span>
<input id="alias-values" name="aliases" placeholder="例如:梦江、梦江南服、MJ" />
</label>
<button class="button button--primary" type="submit">保存别名</button>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>标准区服名</th><th>别名</th><th class="actions">操作</th></tr></thead>
<tbody id="aliases-body"></tbody>
</table>
</div>
</section>
<section class="panel" id="kungfu-panel" role="tabpanel" hidden>
<form class="editor editor--kungfu" id="kungfu-form">
<label>
<span>心法 IDJX3BOX 配装 ID</span>
<input id="kungfu-pzid" name="pzid" type="number" min="1" step="1" placeholder="例如:10014" required />
</label>
<label>
<span>标准心法名</span>
<input id="kungfu-name" name="name" maxlength="64" placeholder="例如:紫霞功" required />
</label>
<label>
<span>别名(最多 5 个,用逗号分隔)</span>
<input id="kungfu-aliases" name="aliases" 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="kungfu-body"></tbody>
</table>
</div>
</section>
</main>
<datalist id="server-options"></datalist>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script type="module" src="./app.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
:root {
color-scheme: light;
--bg: #f7f7f8;
--surface: #ffffff;
--surface-subtle: #fafafa;
--text: #131a2a;
--muted: #687080;
--border: #e1e3e8;
--accent: #c6342d;
--accent-hover: #a92823;
--accent-soft: #fff0ee;
--success: #2f7d4a;
--danger: #b82d28;
--focus: #8dafed;
--shadow: 0 10px 28px rgba(24, 30, 44, 0.07);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #111318;
--surface: #191c23;
--surface-subtle: #15181e;
--text: #f1f3f7;
--muted: #a2a9b5;
--border: #30343d;
--accent: #e65b53;
--accent-hover: #f1746d;
--accent-soft: #38211f;
--success: #65b77e;
--danger: #f1746d;
--focus: #86aef4;
--shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; line-height: 1.55; }
button, input { font: inherit; }
.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; }
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); }
.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: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:hover { background: var(--accent-hover); transform: translateY(-1px); }
.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)); }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); }
.summary__item { display: flex; min-height: 112px; flex-direction: column; justify-content: center; padding: 22px 30px; }
.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); }
.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.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[hidden] { display: none; }
.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--aliases { grid-template-columns: minmax(220px, .8fr) minmax(320px, 1.5fr) auto; }
.editor--kungfu { grid-template-columns: minmax(210px, .65fr) minmax(210px, .8fr) minmax(300px, 1.35fr) auto; }
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::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; }
.section-intro { padding: 22px; border-bottom: 1px solid var(--border); }
.section-intro h2 { margin: 0; font-size: 18px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 15px 20px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
th { background: var(--surface-subtle); color: var(--muted); font-size: 13px; font-weight: 700; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: color-mix(in srgb, var(--accent-soft) 42%, transparent); }
.actions { width: 170px; white-space: nowrap; }
.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; }
.link-button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.link-button--danger { color: var(--danger); }
.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--on { color: var(--success); }
.state--off { color: var(--muted); }
.tags { display: flex; flex-wrap: wrap; gap: 7px; max-width: 620px; }
.tag { display: inline-flex; padding: 3px 8px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 650; }
.empty { padding: 42px 20px; color: var(--muted); text-align: center; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 5; max-width: min(380px, calc(100vw - 48px)); padding: 13px 18px; border-left: 4px solid var(--success); border-radius: 8px; background: var(--surface); box-shadow: 0 14px 38px rgba(0, 0, 0, .18); opacity: 0; pointer-events: none; transform: translateY(14px); transition: 180ms ease; }
.toast.is-visible { opacity: 1; transform: translateY(0); }
.toast.is-error { border-left-color: var(--danger); }
@media (max-width: 760px) {
.shell { width: min(100% - 24px, 1260px); padding-top: 20px; }
.page-header { align-items: stretch; flex-direction: column; }
.summary { grid-template-columns: 1fr; }
.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; }
.tab { flex: 0 0 auto; }
.editor, .editor--aliases, .editor--kungfu { grid-template-columns: 1fr; }
.table-wrap { overflow: visible; }
table, tbody, tr, td { display: block; width: 100%; }
thead { display: none; }
tbody tr { padding: 7px 0; border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: 0; }
td { display: grid; grid-template-columns: minmax(92px, 30%) minmax(0, 1fr); gap: 12px; padding: 8px 16px; border: 0; overflow-wrap: anywhere; }
td::before { color: var(--muted); content: attr(data-label); font-size: 12px; font-weight: 700; }
td.actions { grid-template-columns: minmax(92px, 30%) auto auto; align-items: start; justify-content: start; width: 100%; white-space: normal; }
td.empty { display: block; padding: 36px 16px; }
td.empty::before { content: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+1
View File
@@ -197,6 +197,7 @@
<section class="section span-3 section--success push-section"> <section class="section span-3 section--success push-section">
<div class="section-title">推送功能</div> <div class="section-title">推送功能</div>
<div class="section-body"> <div class="section-body">
<div class="feature"><div class="feature-name">绑定区服</div><div class="commands"><span class="command">绑定区服 [区服]</span><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 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>
+40 -29
View File
@@ -4,17 +4,28 @@
1. 搜索 data-castle="据点名" 定位据点。 1. 搜索 data-castle="据点名" 定位据点。
2. 第一个 img 的 left/top 是领地图层坐标。 2. 第一个 img 的 left/top 是领地图层坐标。
3. 第二个 img 的 left/top 是据点图标坐标。 3. 第二个 img 的 left/top 是据点图标坐标。
4. span 的 left/top 是竖排据点名坐标。 4. span 的 left/top 是竖排据点名坐标;帮会名和防守标识根据图标坐标自动定位
坐标均以 1339 × 916 的 background.png 左上角为 (0, 0)。 坐标均以 1339 × 916 的 background.png 左上角为 (0, 0)。
#} #}
{% set castle_map = {} %} {% set castle_map = {} %}
{% for item in items %} {% for item in items %}
{% set _ = castle_map.update({item.castle_name: item.camp_key}) %} {% set _ = castle_map.update({item.castle_name: item}) %}
{% endfor %} {% endfor %}
{% macro camp(name) %}{{ castle_map.get(name, '') }}{% endmacro %}
{% macro image(name) %}{{ icons.sand.get(name, '') }}{% endmacro %} {% macro image(name) %}{{ icons.sand.get(name, '') }}{% endmacro %}
{% macro castle(name, territory_left, territory_top, icon_name, icon_left, icon_top, name_left, name_top) %}
{% set item = castle_map.get(name) %}
{% if item %}
<div class="sand-castle" data-castle="{{ name }}" data-guild="{{ item.tong_name }}" data-defend-count="{{ item.defend_count }}">
<img class="sand-layer sand-layer--territory" style="left:{{ territory_left }}px;top:{{ territory_top }}px" src="{{ image(item.camp_key ~ '_' ~ name) }}" alt="">
<img class="sand-layer sand-layer--icon" style="left:{{ icon_left }}px;top:{{ icon_top }}px" src="{{ image('图标_' ~ item.camp_key ~ '_' ~ icon_name) }}" alt="">
<span class="sand-castle__name" style="left:{{ name_left }}px;top:{{ name_top }}px">{{ name }}</span>
{% if item.tong_name %}<span class="sand-castle__guild" style="left:{{ icon_left - 32 }}px;top:{{ icon_top + 42 }}px">{{ item.tong_name }}</span>{% endif %}
{% if item.defend_count in [1, 2, 3] %}<img class="sand-layer sand-layer--defense" style="left:{{ icon_left - 27 }}px;top:{{ icon_top + 1 }}px" src="{{ image('防御' ~ item.defend_count) }}" alt="防守{{ item.defend_count }}层">{% endif %}
</div>
{% endif %}
{% endmacro %}
<main <main
class="sand-map" class="sand-map"
@@ -30,32 +41,32 @@
<span class="sand-castle__name" style="left:380px;top:103px">恶人谷</span> <span class="sand-castle__name" style="left:380px;top:103px">恶人谷</span>
</div> </div>
{% if camp('凛风堡') %}<div class="sand-castle" data-castle="凛风堡"><img class="sand-layer sand-layer--territory" style="left:299px;top:117px" src="{{ image(camp('凛风堡') ~ '_凛风堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:435px;top:186px" src="{{ image('图标_' ~ camp('凛风堡') ~ '_凛风堡') }}" alt=""><span class="sand-castle__name" style="left:485px;top:176px">凛风堡</span></div>{% endif %} {{ castle('凛风堡', 299, 117, '凛风堡', 435, 186, 485, 176) }}
{% if camp('神池岭') %}<div class="sand-castle" data-castle="神池岭"><img class="sand-layer sand-layer--territory" style="left:260px;top:259px" src="{{ image(camp('神池岭') ~ '_神池岭') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:280px;top:288px" src="{{ image('图标_' ~ camp('神池岭') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:320px;top:278px">神池岭</span></div>{% endif %} {{ castle('神池岭', 260, 259, '02', 280, 288, 320, 278) }}
{% if camp('烈日岗') %}<div class="sand-castle" data-castle="烈日岗"><img class="sand-layer sand-layer--territory" style="left:160px;top:321px" src="{{ image(camp('烈日岗') ~ '_烈日岗') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:239px;top:402px" src="{{ image('图标_' ~ camp('烈日岗') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:285px;top:392px">烈日岗</span></div>{% endif %} {{ castle('烈日岗', 160, 321, '01', 239, 402, 285, 392) }}
{% if camp('扶风郡') %}<div class="sand-castle" data-castle="扶风郡"><img class="sand-layer sand-layer--territory" style="left:446px;top:241px" src="{{ image(camp('扶风郡') ~ '_扶风郡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:477px;top:276px" src="{{ image('图标_' ~ camp('扶风郡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:527px;top:266px">扶风郡</span></div>{% endif %} {{ castle('扶风郡', 446, 241, '01', 477, 276, 527, 266) }}
{% if camp('世外坡') %}<div class="sand-castle" data-castle="世外坡"><img class="sand-layer sand-layer--territory" style="left:384px;top:286px" src="{{ image(camp('世外坡') ~ '_世外坡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:451px;top:361px" src="{{ image('图标_' ~ camp('世外坡') ~ '_05') }}" alt=""><span class="sand-castle__name" style="left:491px;top:351px">世外坡</span></div>{% endif %} {{ castle('世外坡', 384, 286, '05', 451, 361, 491, 351) }}
{% if camp('飞沙关') %}<div class="sand-castle" data-castle="飞沙关"><img class="sand-layer sand-layer--territory" style="left:513px;top:187px" src="{{ image(camp('飞沙关') ~ '_飞沙关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:521px;top:196px" src="{{ image('图标_' ~ camp('飞沙关') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:561px;top:186px">飞沙关</span></div>{% endif %} {{ castle('飞沙关', 513, 187, '01', 521, 196, 561, 186) }}
{% if camp('龙门镇') %}<div class="sand-castle" data-castle="龙门镇"><img class="sand-layer sand-layer--territory" style="left:546px;top:171px" src="{{ image(camp('龙门镇') ~ '_龙门镇') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:601px;top:216px" src="{{ image('图标_' ~ camp('龙门镇') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:641px;top:206px">龙门镇</span></div>{% endif %} {{ castle('龙门镇', 546, 171, '02', 601, 216, 641, 206) }}
{% if camp('凤鸣堡') %}<div class="sand-castle" data-castle="凤鸣堡"><img class="sand-layer sand-layer--territory" style="left:174px;top:547px" src="{{ image(camp('凤鸣堡') ~ '_凤鸣堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:213px;top:576px" src="{{ image('图标_' ~ camp('凤鸣堡') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:253px;top:566px">凤鸣堡</span></div>{% endif %} {{ castle('凤鸣堡', 174, 547, '04', 213, 576, 253, 566) }}
{% if camp('惊虬谷') %}<div class="sand-castle" data-castle="惊虬谷"><img class="sand-layer sand-layer--territory" style="left:161px;top:438px" src="{{ image(camp('惊虬谷') ~ '_惊虬谷') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:283px;top:498px" src="{{ image('图标_' ~ camp('惊虬谷') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:323px;top:488px">惊虬谷</span></div>{% endif %} {{ castle('惊虬谷', 161, 438, '01', 283, 498, 323, 488) }}
{% if camp('日月崖') %}<div class="sand-castle" data-castle="日月崖"><img class="sand-layer sand-layer--territory" style="left:406px;top:408px" src="{{ image(camp('日月崖') ~ '_日月崖') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:483px;top:482px" src="{{ image('图标_' ~ camp('日月崖') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:523px;top:472px">日月崖</span></div>{% endif %} {{ castle('日月崖', 406, 408, '02', 483, 482, 523, 472) }}
{% if camp('卧龙坡') %}<div class="sand-castle" data-castle="卧龙坡"><img class="sand-layer sand-layer--territory" style="left:377px;top:529px" src="{{ image(camp('卧龙坡') ~ '_卧龙坡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:471px;top:578px" src="{{ image('图标_' ~ camp('卧龙坡') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:511px;top:568px">卧龙坡</span></div>{% endif %} {{ castle('卧龙坡', 377, 529, '03', 471, 578, 511, 568) }}
{% if camp('啖杏林') %}<div class="sand-castle" data-castle="啖杏林"><img class="sand-layer sand-layer--territory" style="left:653px;top:205px" src="{{ image(camp('啖杏林') ~ '_啖杏林') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:667px;top:226px" src="{{ image('图标_' ~ camp('啖杏林') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:687px;top:266px">啖杏林</span></div>{% endif %} {{ castle('啖杏林', 653, 205, '01', 667, 226, 687, 266) }}
{% if camp('枫湖寨') %}<div class="sand-castle" data-castle="枫湖寨"><img class="sand-layer sand-layer--territory" style="left:650px;top:254px" src="{{ image(camp('枫湖寨') ~ '_枫湖寨') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:729px;top:294px" src="{{ image('图标_' ~ camp('枫湖寨') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:759px;top:284px">枫湖寨</span></div>{% endif %} {{ castle('枫湖寨', 650, 254, '03', 729, 294, 759, 284) }}
{% if camp('不空关') %}<div class="sand-castle" data-castle="不空关"><img class="sand-layer sand-layer--territory" style="left:543px;top:422px" src="{{ image(camp('不空关') ~ '_不空关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:631px;top:446px" src="{{ image('图标_' ~ camp('不空关') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:671px;top:436px">不空关</span></div>{% endif %} {{ castle('不空关', 543, 422, '04', 631, 446, 671, 436) }}
{% if camp('激流坞') %}<div class="sand-castle" data-castle="激流坞"><img class="sand-layer sand-layer--territory" style="left:583px;top:485px" src="{{ image(camp('激流坞') ~ '_激流坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:609px;top:520px" src="{{ image('图标_' ~ camp('激流坞') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:649px;top:510px">激流坞</span></div>{% endif %} {{ castle('激流坞', 583, 485, '01', 609, 520, 649, 510) }}
{% if camp('澜沧城') %}<div class="sand-castle" data-castle="澜沧城"><img class="sand-layer sand-layer--territory" style="left:274px;top:680px" src="{{ image(camp('澜沧城') ~ '_澜沧城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:321px;top:772px" src="{{ image('图标_' ~ camp('澜沧城') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:361px;top:762px">澜沧城</span></div>{% endif %} {{ castle('澜沧城', 274, 680, '03', 321, 772, 361, 762) }}
{% if camp('霜戈堡') %}<div class="sand-castle" data-castle="霜戈堡"><img class="sand-layer sand-layer--territory" style="left:201px;top:656px" src="{{ image(camp('霜戈堡') ~ '_霜戈堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:253px;top:694px" src="{{ image('图标_' ~ camp('霜戈堡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:293px;top:684px">霜戈堡</span></div>{% endif %} {{ castle('霜戈堡', 201, 656, '01', 253, 694, 293, 684) }}
{% if camp('金门关') %}<div class="sand-castle" data-castle="金门关"><img class="sand-layer sand-layer--territory" style="left:788px;top:177px" src="{{ image(camp('金门关') ~ '_金门关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:835px;top:266px" src="{{ image('图标_' ~ camp('金门关') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:875px;top:256px">金门关</span></div>{% endif %} {{ castle('金门关', 788, 177, '04', 835, 266, 875, 256) }}
{% if camp('青云坞') %}<div class="sand-castle" data-castle="青云坞"><img class="sand-layer sand-layer--territory" style="left:884px;top:198px" src="{{ image(camp('青云坞') ~ '_青云坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:919px;top:232px" src="{{ image('图标_' ~ camp('青云坞') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:959px;top:222px">青云坞</span></div>{% endif %} {{ castle('青云坞', 884, 198, '01', 919, 232, 959, 222) }}
{% if camp('逐鹿坪') %}<div class="sand-castle" data-castle="逐鹿坪"><img class="sand-layer sand-layer--territory" style="left:724px;top:445px" src="{{ image(camp('逐鹿坪') ~ '_逐鹿坪') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:773px;top:488px" src="{{ image('图标_' ~ camp('逐鹿坪') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:813px;top:478px">逐鹿坪</span></div>{% endif %} {{ castle('逐鹿坪', 724, 445, '03', 773, 488, 813, 478) }}
{% if camp('盘龙坞') %}<div class="sand-castle" data-castle="盘龙坞"><img class="sand-layer sand-layer--territory" style="left:653px;top:530px" src="{{ image(camp('盘龙坞') ~ '_盘龙坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:729px;top:590px" src="{{ image('图标_' ~ camp('盘龙坞') ~ '_05') }}" alt=""><span class="sand-castle__name" style="left:769px;top:580px">盘龙坞</span></div>{% endif %} {{ castle('盘龙坞', 653, 530, '05', 729, 590, 769, 580) }}
{% if camp('千岩关') %}<div class="sand-castle" data-castle="千岩关"><img class="sand-layer sand-layer--territory" style="left:456px;top:635px" src="{{ image(camp('千岩关') ~ '_千岩关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:541px;top:788px" src="{{ image('图标_' ~ camp('千岩关') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:581px;top:778px">千岩关</span></div>{% endif %} {{ castle('千岩关', 456, 635, '01', 541, 788, 581, 778) }}
{% if camp('大理山城') %}<div class="sand-castle" data-castle="大理山城"><img class="sand-layer sand-layer--territory" style="left:455px;top:660px" src="{{ image(camp('大理山城') ~ '_大理山城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:487px;top:676px" src="{{ image('图标_' ~ camp('大理山城') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:527px;top:666px">大理山城</span></div>{% endif %} {{ castle('大理山城', 455, 660, '04', 487, 676, 527, 666) }}
{% if camp('秋雨堡') %}<div class="sand-castle" data-castle="秋雨堡"><img class="sand-layer sand-layer--territory" style="left:774px;top:372px" src="{{ image(camp('秋雨堡') ~ '_秋雨堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:825px;top:424px" src="{{ image('图标_' ~ camp('秋雨堡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:865px;top:414px">秋雨堡</span></div>{% endif %} {{ castle('秋雨堡', 774, 372, '01', 825, 424, 865, 414) }}
{% if camp('红莲岗') %}<div class="sand-castle" data-castle="红莲岗"><img class="sand-layer sand-layer--territory" style="left:758px;top:313px" src="{{ image(camp('红莲岗') ~ '_红莲岗') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:849px;top:338px" src="{{ image('图标_' ~ camp('红莲岗') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:889px;top:328px">红莲岗</span></div>{% endif %} {{ castle('红莲岗', 758, 313, '02', 849, 338, 889, 328) }}
{% if camp('武王城') %}<div class="sand-castle" data-castle="武王城"><img class="sand-layer sand-layer--territory" style="left:713px;top:484px" src="{{ image(camp('武王城') ~ '_武王城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:857px;top:614px" src="{{ image('图标_' ~ camp('武王城') ~ '_武王城') }}" alt=""><span class="sand-castle__name" style="left:917px;top:604px">武王城</span></div>{% endif %} {{ castle('武王城', 713, 484, '武王城', 857, 614, 917, 604) }}
<div class="sand-castle" data-castle="浩气盟"> <div class="sand-castle" data-castle="浩气盟">
<img class="sand-layer sand-layer--territory" style="left:834px;top:577px" src="{{ image('浩_浩气盟') }}" alt=""> <img class="sand-layer sand-layer--territory" style="left:834px;top:577px" src="{{ image('浩_浩气盟') }}" alt="">
+28 -6
View File
@@ -1,14 +1,16 @@
html, html,
body.jx3-template--shapan { body.jx3-template--shapan {
width: fit-content; width: 1339px;
height: 954px; height: 916px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
background: #fff !important; background: transparent !important;
} }
body.jx3-template--shapan { body.jx3-template--shapan {
padding: 22px 15px 16px 55px; margin: 0 !important;
padding: 0 !important;
background-image: none !important;
} }
.jx3-template--shapan .jx3-frame { .jx3-template--shapan .jx3-frame {
@@ -53,6 +55,7 @@ body.jx3-template--shapan {
.sand-layer--territory { .sand-layer--territory {
z-index: 1; z-index: 1;
transform: translateX(12px);
} }
.sand-map__frontline { .sand-map__frontline {
@@ -72,6 +75,12 @@ body.jx3-template--shapan {
z-index: 4; z-index: 4;
} }
.sand-layer--defense {
z-index: 7;
width: 38px;
height: 41px;
}
.sand-castle__name { .sand-castle__name {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -79,7 +88,20 @@ body.jx3-template--shapan {
width: 1em; width: 1em;
color: #fffcd0; color: #fffcd0;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 12px; font-size: 15px;
line-height: 18px; line-height: 20px;
text-shadow: 1px 1px 1px #000; text-shadow: 1px 1px 1px #000;
} }
.sand-castle__guild {
position: absolute;
z-index: 6;
width: 110px;
color: #fffcd0;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 18px;
text-align: center;
text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
}