diff --git a/.astrbot-plugin/i18n/zh-CN.json b/.astrbot-plugin/i18n/zh-CN.json
new file mode 100644
index 0000000..4a760cb
--- /dev/null
+++ b/.astrbot-plugin/i18n/zh-CN.json
@@ -0,0 +1,8 @@
+{
+ "pages": {
+ "server-management": {
+ "title": "剑网三插件管理",
+ "description": "管理会话区服绑定、事件订阅状态、区服别名和心法别名。"
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0eb8a7e..a880825 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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:
沙盘功能改用 JX3API `/sand/records` 据点数据接口,并使用随插件分发的沙盘底图、阵营领地图层和据点图标在本地渲染完整图片。
diff --git a/README.md b/README.md
index 9eba90f..a2ac70f 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
### 环境要求
-- AstrBot `>= 4.11.0`
+- AstrBot `>= 4.24.1`(管理页依赖 Plugin Pages)
- 能够运行当前 AstrBot 版本的 Python 环境
- 能够访问插件使用的外部数据源
- 图片类指令需要 AstrBot 的 HTML 渲染能力正常可用
@@ -80,13 +80,12 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
| --- | --- | --- | --- |
| `prefix.enable` | `bool` | `true` | 是否启用指令前缀检查 |
| `prefix.text` | `string` | `剑三` | 指令前缀内容 |
-| `server` | `string` | `梦江南` | 查询指令未输入服务器时使用的默认服务器 |
| `jx3api_token` | `string` | 空 | JX3API Token |
| `jx3api_ticket` | `string` | 空 | 部分名剑和心法接口需要的推栏 Ticket |
| `jx3api_wss` | `string` | `wss://socket.nicemoe.cn` | JX3API 事件通道地址 |
| `jx3api_wss_token` | `string` | 空 | 事件版令牌;免费事件无需填写 |
-会话订阅不再写入插件配置,而是通过 `事件推送` 指令保存到本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。
+会话区服绑定、区服别名和事件订阅均保存在本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。
### 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 |
| `阵营奉献 [阵营]` | 查询阵营奉献事件,固定最多 50 条;图片 | Token |
-| `烟花 [服务器] [角色]` | 查询烟花记录;服务器为空时使用配置值;图片 | Token |
+| `烟花 服务器 [角色]` | 查询指定服务器的烟花记录;已绑定会话可省略服务器;图片 | Token |
| `刷马 服务器` | 查询刷马聊天情报;文本 | Token |
| `马场 服务器` | 查询未过期马场记录;文本 | Token |
@@ -194,7 +204,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| 指令 | 说明与输出 | 凭据 |
| --- | --- | --- |
| `帮战 服务器` | 帮战记录;图片 | 无 |
-| `沙盘 [服务器]` | 查询 JX3API 据点归属并使用本地图层渲染阵营沙盘;服务器可省略 | Token |
+| `沙盘 服务器` | 查询 JX3API 据点归属并使用本地图层渲染阵营沙盘;已绑定会话可省略服务器 | Token |
| `诛恶 服务器` | 诛恶事件,固定最多 20 条;图片 | Token |
### 角色名片与奇遇
@@ -290,7 +300,11 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `事件推送 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 参数适配"]
D --> E1["JX3APIService"]
D --> E2["JX3BOXService"]
- D --> E3["BiLeidata / EventPushService"]
+ D --> E3["BiLeidata / EventPushService / KungfuAliasService"]
E1 --> F["APIClient"]
E2 --> F
F --> G["外部 HTTP 数据源"]
- E3 --> H["plugin_data.db / local_data.db"]
+ E3 --> H["local_data.db"]
E1 --> I["标准返回对象"]
E2 --> I
E3 --> I
@@ -318,18 +332,21 @@ flowchart LR
`main.py` 中的 `Jx3ApiPlugin` 是插件入口。构造阶段完成以下工作:
-1. 读取前缀、默认服务器、Token、Ticket 和 WebSocket 配置。
-2. 计算插件目录、AstrBot 数据目录、模板目录和两个 SQLite 文件路径。
+1. 读取前缀、Token、Ticket 和 WebSocket 配置。
+2. 计算插件目录、AstrBot 数据目录、本地 SQLite 和模板目录路径。
3. 把 `templates/img`、`templates/img/sand`、`templates/sect`、`templates/serendipity` 中的图片读取为 base64 Data URL。
-4. 创建本地数据库、随包数据库、两个数据服务、避雷服务、事件推送服务和消息构建器。
+4. 创建本地数据库、两个数据服务、心法别名服务、避雷服务、事件推送服务和消息构建器。
异步初始化阶段会连接数据库并创建以下本地表:
- `bilei`:避雷记录。
- `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。
+- `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。
+- `server_aliases`:保存标准区服名及其 JSON 别名列表。
+- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
- `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. 指令分发
@@ -423,12 +440,13 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
### 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`。
@@ -436,7 +454,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
`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` 轮询状态表会被删除。
@@ -452,17 +470,22 @@ astrbot_plugin_jx3/
├── CHANGELOG.md # 版本更新记录
├── LICENSE # GNU AGPL v3
├── data/
-│ └── plugin_data.db # 随包心法/别名基础数据
+│ ├── kungfu.json # 心法、别名与配装 ID 默认种子
+│ └── server_aliases.json # 区服别名默认种子
├── core/
│ ├── jx3api_data.py # JX3API 业务服务
│ ├── jx3box_data.py # JX3BOX 业务服务与缓存逻辑
│ ├── message.py # 文本、图片、消息链和两轮会话构建
│ ├── request.py # aiohttp 请求封装
│ ├── event_push.py # WebSocket 事件通道与会话订阅
+│ ├── server_binding.py # 会话区服绑定、别名与区服目录
+│ ├── kungfu_alias.py # 本地心法目录初始化、校验与 WebUI 写入
│ ├── bilei_data.py # 避雷数据增删改查
│ ├── sqlite.py # aiosqlite 通用封装
│ ├── fun_basic.py # 图标、时间和货币格式化工具
│ └── template.py # 模板组合、异步读取与内存缓存
+├── pages/
+│ └── server-management/ # AstrBot 会话管理 WebUI
└── templates/
├── layouts/
│ └── 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` 刷新。
3. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
4. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
diff --git a/_conf_schema.json b/_conf_schema.json
index 61df8ac..2a30491 100644
--- a/_conf_schema.json
+++ b/_conf_schema.json
@@ -17,12 +17,6 @@
}
}
},
- "server": {
- "description": "默认服务器",
- "type": "string",
- "default": "梦江南",
- "hint": "在指令中未输入服务器时,默认查询的服务器"
- },
"jx3api_token": {
"description": "JX3API Token",
"type": "string",
diff --git a/core/event_push.py b/core/event_push.py
index 8d82c53..1f619ce 100644
--- a/core/event_push.py
+++ b/core/event_push.py
@@ -14,6 +14,7 @@ from astrbot.api.event import MessageChain
from astrbot.api.star import Context
from .sqlite import AsyncSQLiteDB
+from .server_binding import ServerBindingService
DEFAULT_WSS_URL = "wss://socket.nicemoe.cn"
@@ -70,10 +71,12 @@ class EventPushService:
context: Context,
config: AstrBotConfig,
sqlite: AsyncSQLiteDB,
+ server_binding: ServerBindingService,
):
self.context = context
self.config = config
self.sql = sqlite
+ self.server_binding = server_binding
self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip()
self.token = str(config.get("jx3api_wss_token", "") or "").strip()
self._runner: Optional[asyncio.Task] = None
@@ -252,6 +255,16 @@ class EventPushService:
return
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:
return
@@ -271,13 +284,38 @@ class EventPushService:
message_chain = MessageChain().message(text)
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)
rows = await self.sql.fetch_all(
- f"SELECT session_id FROM event_push_subscriptions "
- f"WHERE enabled=1 AND {column}=1"
+ f"SELECT subscriptions.session_id, "
+ 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(
self,
diff --git a/core/jx3api_data.py b/core/jx3api_data.py
index 42a1ddc..8127bd5 100644
--- a/core/jx3api_data.py
+++ b/core/jx3api_data.py
@@ -10,7 +10,7 @@ from astrbot.api import logger
from astrbot.api import AstrBotConfig
import astrbot.api.message_components as Comp
-from .request import APIClient
+from .request import APIClient, APIErrorResponse
from .sqlite import AsyncSQLiteDB
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:
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]:
"""初始化标准的返回数据结构"""
@@ -75,7 +91,13 @@ class JX3APIService:
base_url = "https://www.jx3api.com"
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:
logger.warning(f"获取接口信息失败或返回空数据: {api_url}")
@@ -100,6 +122,9 @@ class JX3APIService:
return_data = self._init_return_data()
data = await self._base_request(path, params)
+ if isinstance(data, APIErrorResponse):
+ return_data["msg"] = data.message or "获取接口信息失败"
+ return return_data
if data is None:
return_data["msg"] = "获取接口信息失败"
return return_data
diff --git a/core/kungfu_alias.py b/core/kungfu_alias.py
new file mode 100644
index 0000000..eb6f0db
--- /dev/null
+++ b/core/kungfu_alias.py
@@ -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()
diff --git a/core/message.py b/core/message.py
index ff65309..239a450 100644
--- a/core/message.py
+++ b/core/message.py
@@ -38,14 +38,12 @@ class MessageBuilder:
)
def __init__(self,
- server: str,
jx3api: JX3APIService,
jx3box: JX3BOXService,
bilei: BiLeidata,
event_push: EventPushService,
icons: dict[str, dict[str, str]]
):
- self.server = server
self.jx3api = jx3api
self.jx3box = jx3box
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):
"""最终将数据整理成文本发送"""
data= await action()
@@ -89,7 +80,12 @@ class MessageBuilder:
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()
try:
@@ -101,6 +97,9 @@ class MessageBuilder:
"omit_background": False,
"type": "jpeg"
}
+ options.update(render_options or {})
+ if options.get("type") == "png":
+ options.pop("quality", None)
data["data"]["icons"] = self.icons
url = await self.html_render(data["temp"], data["data"], options=options)
await event.send(event.image_result(url))
@@ -109,7 +108,7 @@ class MessageBuilder:
except Exception as e:
logger.error(f"功能函数执行错误: {e}")
- await event.send(event.plain_result("猪脑过载,请稍后再试"))
+ await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def image_msg(self, event: AstrMessageEvent, action):
@@ -324,9 +323,9 @@ class MessageBuilder:
""" 阵营事件 阵营"""
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 ):
""" 刷马 服务器"""
@@ -448,10 +447,13 @@ class MessageBuilder:
""" 帮战 服务器"""
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(event, lambda: self.jx3api.shapan(server))
+ return await self.T2I_image_msg(
+ event,
+ lambda: self.jx3api.shapan(server),
+ render_options={"omit_background": True, "type": "png"},
+ )
async def zhueevent(self, event: AstrMessageEvent, server: str):
""" 诛恶事件 服务器"""
diff --git a/core/request.py b/core/request.py
index e204057..893decd 100644
--- a/core/request.py
+++ b/core/request.py
@@ -2,11 +2,20 @@
import json
import aiohttp
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 astrbot.api import logger
+
+@dataclass(frozen=True, slots=True)
+class APIErrorResponse:
+ """保留接口业务错误,避免提取 data 时丢失 code/msg。"""
+
+ code: Any
+ message: str
+
class APIClient:
"""
API客户端类
@@ -102,7 +111,7 @@ class APIClient:
return None
def _validate_api_payload(self, data: Any) -> Any:
- """校验业务层面的 JSON 数据结构"""
+ """校验并规范化 JSON 数据结构。"""
if not data:
logger.error("API返回空数据")
return None
@@ -114,32 +123,70 @@ class APIClient:
except json.JSONDecodeError:
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
- 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 请求封装"""
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)"""
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:
return None
if isinstance(data, bytes):
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):
return data.get(key, {})
return data
@@ -188,4 +235,4 @@ class APIClient:
current_page += 1
logger.info(f"已获取第 {current_page} 页数据")
- return all_data
\ No newline at end of file
+ return all_data
diff --git a/core/server_binding.py b/core/server_binding.py
new file mode 100644
index 0000000..2a03b00
--- /dev/null
+++ b/core/server_binding.py
@@ -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()
diff --git a/data/kungfu.json b/data/kungfu.json
new file mode 100644
index 0000000..8adb3c3
--- /dev/null
+++ b/data/kungfu.json
@@ -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": ["无相楼"]}
+]
diff --git a/data/plugin_data.db b/data/plugin_data.db
deleted file mode 100644
index 2b77b2e..0000000
Binary files a/data/plugin_data.db and /dev/null differ
diff --git a/data/server_aliases.json b/data/server_aliases.json
new file mode 100644
index 0000000..8052f64
--- /dev/null
+++ b/data/server_aliases.json
@@ -0,0 +1,7 @@
+[
+ {"server": "梦江南", "aliases": ["双梦"]},
+ {"server": "唯我独尊", "aliases": ["唯满侠"]},
+ {"server": "乾坤一掷", "aliases": ["华乾"]},
+ {"server": "剑胆琴心", "aliases": ["煎蛋"]},
+ {"server": "斗转星移", "aliases": ["姨妈"]}
+]
diff --git a/main.py b/main.py
index ce61cab..142265c 100644
--- a/main.py
+++ b/main.py
@@ -1,24 +1,32 @@
import inspect
+import re
from pathlib import Path
+from sys import maxsize
from typing import cast
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star, register, StarTools
from astrbot.api import logger
from astrbot.api import AstrBotConfig
+from astrbot.api.web import error_response, json_response, request
from .core.sqlite import AsyncSQLiteDB
from .core.jx3api_data import JX3APIService
from .core.jx3box_data import JX3BOXService
-from .core.event_push import EventPushService
+from .core.event_push import EVENT_NAMES, EventPushService
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.fun_basic import load_as_base64
+
+PLUGIN_NAME = "astrbot_plugin_jx3"
+
@register("astrbot_plugin_jx3",
"fxdyz",
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
- "3.3.1",
+ "3.4.0",
"https://github.com/qsc20001102/astrbot_plugin_jx3"
)
class Jx3ApiPlugin(Star):
@@ -34,16 +42,14 @@ class Jx3ApiPlugin(Star):
else:
logger.info(f"未启用指令前缀功能。")
- # 默认服务器
- self.server = self.conf.get("server","梦江南")
- logger.info(f"配置加载默认服务器:{self.server}")
-
# 获取数据文件路径
self.get_data_path()
# 加载图片base64编码
self.load_local_base64()
# 构造所有类
self.create_all()
+ # 注册插件管理页接口
+ self._register_web_apis(context)
# 声明指令集
@@ -58,9 +64,13 @@ class Jx3ApiPlugin(Star):
# 数据库初始化
await self.init_bilei_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()
@@ -92,9 +102,6 @@ class Jx3ApiPlugin(Star):
if self.local_sql_db:
await self.local_sql_db.close()
- if self.plugin_sql_db:
- await self.plugin_sql_db.close()
-
logger.info("jx3api插件已卸载/停用")
@@ -108,8 +115,8 @@ class Jx3ApiPlugin(Star):
# SQLite本地路径
self.local_data_path = self.local_data_dir / "local_data.db"
- # SQLite插件路径
- self.plugin_data_path = self.plugin_data_dir /"plugin_data.db"
+ self.kungfu_seed_path = self.plugin_data_dir / "kungfu.json"
+ self.server_alias_seed_path = self.plugin_data_dir / "server_aliases.json"
# 图片文件路径
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.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_sand}")
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.plugin_sql_db = AsyncSQLiteDB(str(self.plugin_data_path))
# 剑网三功能实例化
self.bilei = BiLeidata(self.local_sql_db)
- self.jx3api = JX3APIService(self.conf, self.plugin_sql_db, self.local_sql_db)
- self.jx3box = JX3BOXService(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.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(
cast(Context, self.context),
self.conf,
self.local_sql_db,
+ self.server_binding,
)
self.jx3cmd = MessageBuilder(
- self.server,
self.jx3api,
self.jx3box,
self.bilei,
@@ -296,6 +311,8 @@ class Jx3ApiPlugin(Star):
"资历": self. jx3cmd.zili,
"交易行": self. jx3cmd.jiaoyihang,
+ "绑定区服": self.bind_server,
+ "解绑区服": self.unbind_server,
"事件推送": self.jx3cmd.shijian_tuisong,
"避雷添加": self.jx3cmd.bilei_add,
"避雷查看": self.jx3cmd.bilei_all,
@@ -313,7 +330,9 @@ class Jx3ApiPlugin(Star):
# 前缀模式
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):
text = text[len(prefix):].strip()
else:
@@ -322,6 +341,90 @@ class Jx3ApiPlugin(Star):
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]):
"""指令执行函数"""
@@ -361,27 +464,29 @@ class Jx3ApiPlugin(Star):
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):
"""解析所有消息"""
if not self.command_map:
logger.debug("插件尚未初始化完成,忽略消息")
return
- # 获取消息
- parts = self.parse_message(event.message_str)
- if not parts:
+ command = self.resolve_command(event)
+ if not command:
logger.debug("未触发指令,忽略消息")
return
- cmd, *args = parts
- handler = self.command_map.get(cmd)
- if not handler:
- logger.debug("指令函数为空,忽略消息")
- return
+ cmd, args, handler = command
+
+ # 一旦确认是本插件指令,立即阻止后续插件和默认 LLM 继续处理。
+ event.stop_event()
+ event.should_call_llm(True)
try:
- event.stop_event()
+ args = await self._prepare_server_args(handler, event, args)
ret = await self._call_with_auto_args(handler, event, args)
if ret is not None:
yield ret
@@ -389,6 +494,142 @@ class Jx3ApiPlugin(Star):
logger.exception(f"指令执行失败: {cmd}, error={e}")
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()})
+
diff --git a/metadata.yaml b/metadata.yaml
index d3128c4..4754777 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -1,10 +1,10 @@
name: astrbot_plugin_jx3
display_name: 剑网三游戏数据查询工具
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
-version: 3.3.1
+version: 3.4.0
author: 飞翔大野猪
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
-astrbot_version: ">=4.11.0"
+astrbot_version: ">=4.24.1"
support_platforms:
- aiocqhttp
- weixin_oc
diff --git a/pages/server-management/app.js b/pages/server-management/app.js
new file mode 100644
index 0000000..a4bc55a
--- /dev/null
+++ b/pages/server-management/app.js
@@ -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);
+}
diff --git a/pages/server-management/index.html b/pages/server-management/index.html
new file mode 100644
index 0000000..a3e7927
--- /dev/null
+++ b/pages/server-management/index.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+ 剑网三插件管理
+
+
+
+
+
+
+
+
+
+ 已绑定会话
+ —
+
+
+ 事件推送会话
+ —
+
+
+ 区服别名
+ —
+
+
+ 心法条目
+ —
+
+
+
+
+
+
+
+
+
+
所有会话事件状态
总开关和已订阅事件均来自本地订阅表。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/server-management/style.css b/pages/server-management/style.css
new file mode 100644
index 0000000..a428d7b
--- /dev/null
+++ b/pages/server-management/style.css
@@ -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; }
+}
diff --git a/templates/pages/helps.html b/templates/pages/helps.html
index 57fff0d..2f8d682 100644
--- a/templates/pages/helps.html
+++ b/templates/pages/helps.html
@@ -197,6 +197,7 @@
推送功能
+
diff --git a/templates/pages/shapan.html b/templates/pages/shapan.html
index 9a1970e..73ee83f 100644
--- a/templates/pages/shapan.html
+++ b/templates/pages/shapan.html
@@ -4,17 +4,28 @@
1. 搜索 data-castle="据点名" 定位据点。
2. 第一个 img 的 left/top 是领地图层坐标。
3. 第二个 img 的 left/top 是据点图标坐标。
-4. span 的 left/top 是竖排据点名坐标。
+4. span 的 left/top 是竖排据点名坐标;帮会名和防守标识根据图标坐标自动定位。
坐标均以 1339 × 916 的 background.png 左上角为 (0, 0)。
#}
{% set castle_map = {} %}
{% for item in items %}
- {% set _ = castle_map.update({item.castle_name: item.camp_key}) %}
+ {% set _ = castle_map.update({item.castle_name: item}) %}
{% endfor %}
-{% macro camp(name) %}{{ castle_map.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 %}
+
+
 }})
+
 }})
+
{{ name }}
+ {% if item.tong_name %}
{{ item.tong_name }}{% endif %}
+ {% if item.defend_count in [1, 2, 3] %}
 }})
{% endif %}
+
+ {% endif %}
+{% endmacro %}
恶人谷
- {% if camp('凛风堡') %} ~ '_凛风堡') }})
凛风堡 {% endif %}
- {% if camp('神池岭') %} ~ '_神池岭') }})
神池岭 {% endif %}
- {% if camp('烈日岗') %} ~ '_烈日岗') }})
烈日岗 {% endif %}
- {% if camp('扶风郡') %} ~ '_扶风郡') }})
扶风郡 {% endif %}
- {% if camp('世外坡') %} ~ '_世外坡') }})
世外坡 {% endif %}
- {% if camp('飞沙关') %} ~ '_飞沙关') }})
飞沙关 {% endif %}
- {% if camp('龙门镇') %} ~ '_龙门镇') }})
龙门镇 {% endif %}
- {% if camp('凤鸣堡') %} ~ '_凤鸣堡') }})
凤鸣堡 {% endif %}
- {% if camp('惊虬谷') %} ~ '_惊虬谷') }})
惊虬谷 {% endif %}
- {% if camp('日月崖') %} ~ '_日月崖') }})
日月崖 {% endif %}
- {% if camp('卧龙坡') %} ~ '_卧龙坡') }})
卧龙坡 {% endif %}
- {% if camp('啖杏林') %} ~ '_啖杏林') }})
啖杏林 {% endif %}
- {% if camp('枫湖寨') %} ~ '_枫湖寨') }})
枫湖寨 {% endif %}
- {% if camp('不空关') %} ~ '_不空关') }})
不空关 {% endif %}
- {% if camp('激流坞') %} ~ '_激流坞') }})
激流坞 {% endif %}
- {% if camp('澜沧城') %} ~ '_澜沧城') }})
澜沧城 {% endif %}
- {% if camp('霜戈堡') %} ~ '_霜戈堡') }})
霜戈堡 {% endif %}
- {% if camp('金门关') %} ~ '_金门关') }})
金门关 {% endif %}
- {% if camp('青云坞') %} ~ '_青云坞') }})
青云坞 {% endif %}
- {% if camp('逐鹿坪') %} ~ '_逐鹿坪') }})
逐鹿坪 {% endif %}
- {% if camp('盘龙坞') %} ~ '_盘龙坞') }})
盘龙坞 {% endif %}
- {% if camp('千岩关') %} ~ '_千岩关') }})
千岩关 {% endif %}
- {% if camp('大理山城') %} ~ '_大理山城') }})
大理山城 {% endif %}
- {% if camp('秋雨堡') %} ~ '_秋雨堡') }})
秋雨堡 {% endif %}
- {% if camp('红莲岗') %} ~ '_红莲岗') }})
红莲岗 {% endif %}
- {% if camp('武王城') %} ~ '_武王城') }})
武王城 {% endif %}
+ {{ castle('凛风堡', 299, 117, '凛风堡', 435, 186, 485, 176) }}
+ {{ castle('神池岭', 260, 259, '02', 280, 288, 320, 278) }}
+ {{ castle('烈日岗', 160, 321, '01', 239, 402, 285, 392) }}
+ {{ castle('扶风郡', 446, 241, '01', 477, 276, 527, 266) }}
+ {{ castle('世外坡', 384, 286, '05', 451, 361, 491, 351) }}
+ {{ castle('飞沙关', 513, 187, '01', 521, 196, 561, 186) }}
+ {{ castle('龙门镇', 546, 171, '02', 601, 216, 641, 206) }}
+ {{ castle('凤鸣堡', 174, 547, '04', 213, 576, 253, 566) }}
+ {{ castle('惊虬谷', 161, 438, '01', 283, 498, 323, 488) }}
+ {{ castle('日月崖', 406, 408, '02', 483, 482, 523, 472) }}
+ {{ castle('卧龙坡', 377, 529, '03', 471, 578, 511, 568) }}
+ {{ castle('啖杏林', 653, 205, '01', 667, 226, 687, 266) }}
+ {{ castle('枫湖寨', 650, 254, '03', 729, 294, 759, 284) }}
+ {{ castle('不空关', 543, 422, '04', 631, 446, 671, 436) }}
+ {{ castle('激流坞', 583, 485, '01', 609, 520, 649, 510) }}
+ {{ castle('澜沧城', 274, 680, '03', 321, 772, 361, 762) }}
+ {{ castle('霜戈堡', 201, 656, '01', 253, 694, 293, 684) }}
+ {{ castle('金门关', 788, 177, '04', 835, 266, 875, 256) }}
+ {{ castle('青云坞', 884, 198, '01', 919, 232, 959, 222) }}
+ {{ castle('逐鹿坪', 724, 445, '03', 773, 488, 813, 478) }}
+ {{ castle('盘龙坞', 653, 530, '05', 729, 590, 769, 580) }}
+ {{ castle('千岩关', 456, 635, '01', 541, 788, 581, 778) }}
+ {{ castle('大理山城', 455, 660, '04', 487, 676, 527, 666) }}
+ {{ castle('秋雨堡', 774, 372, '01', 825, 424, 865, 414) }}
+ {{ castle('红莲岗', 758, 313, '02', 849, 338, 889, 328) }}
+ {{ castle('武王城', 713, 484, '武王城', 857, 614, 917, 604) }}
 }})
diff --git a/templates/styles/pages/shapan.css b/templates/styles/pages/shapan.css
index 255666e..cef6487 100644
--- a/templates/styles/pages/shapan.css
+++ b/templates/styles/pages/shapan.css
@@ -1,14 +1,16 @@
html,
body.jx3-template--shapan {
- width: fit-content;
- height: 954px;
+ width: 1339px;
+ height: 916px;
min-width: 0;
overflow: hidden;
- background: #fff !important;
+ background: transparent !important;
}
body.jx3-template--shapan {
- padding: 22px 15px 16px 55px;
+ margin: 0 !important;
+ padding: 0 !important;
+ background-image: none !important;
}
.jx3-template--shapan .jx3-frame {
@@ -53,6 +55,7 @@ body.jx3-template--shapan {
.sand-layer--territory {
z-index: 1;
+ transform: translateX(12px);
}
.sand-map__frontline {
@@ -72,6 +75,12 @@ body.jx3-template--shapan {
z-index: 4;
}
+.sand-layer--defense {
+ z-index: 7;
+ width: 38px;
+ height: 41px;
+}
+
.sand-castle__name {
position: absolute;
z-index: 5;
@@ -79,7 +88,20 @@ body.jx3-template--shapan {
width: 1em;
color: #fffcd0;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
- font-size: 12px;
- line-height: 18px;
+ font-size: 15px;
+ line-height: 20px;
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;
+}