This commit is contained in:
qsc
2026-09-04 18:29:49 +08:00
parent f872ffae71
commit 3845950862
42 changed files with 808 additions and 1054 deletions
+16
View File
@@ -1,5 +1,21 @@
## 更新记录
### version: 3.4.12026-09-04):
带区服参数的查询新增 `全区` 保留值;即使当前会话已绑定区服,也会向接口传入空区服以查询全区数据。
资历分布由 JX3BOX 重构为 JX3API `/tuilan/achievement` 实时查询,使用固定的总览及 18 个大类菜单;模板直接读取角色信息、`data.total``pieces``seniority``score/totalScore`,并分别兼容总览的“大类 → 小类 → 统计”和指定分类的“小类 → 统计”返回结构。
重构通用两轮会话处理器:首轮函数直接返回 `{"1": "总览", "2": "杂闻"}` 形式的裸键值对,由处理器生成序号文本;用户选择或 10 秒超时后,将所选项裁剪成单项字典交给次轮函数。资历和宏查询均已接入,宏结果统一复用富媒体消息链发送文本及 HTML 渲染附图。
移除 JX3BOX 资历菜单、点数和角色成就拼装逻辑及其本地缓存;旧 `achievement_cache` 仅迁移交易行物品分组后删除,交易行缓存独立保存到 `trade_item_cache`
提高插件指令监听优先级;确认消息属于本插件指令后立即停止后续事件传播,避免重复触发其他插件或默认 LLM 回复。
补齐 JX3API 当前 39 种 WebSocket 事件处理,付费事件不再回退输出原始 JSON;新增缺失的扶摇预告、烟花报时、阵营祭天预告及帮会野外宣战事件,并按官方字段生成可读中文消息。
统一查询图片和插件 WebUI 的表格样式:表头、内容及图标文字组合水平和垂直居中,移除固定列宽并按内容自动分配;合并跨页面可复用的表格、卡片、状态和布局规则,清理重复或失效的页面 CSS。
### version: 3.4.0
移除插件级默认区服,新增按 AstrBot 会话保存的区服绑定。已绑定会话可省略查询指令中的区服参数,完整填写区服时仍优先查询指定区服。
+20 -20
View File
@@ -15,19 +15,19 @@
- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、副本、家园、社区等场景。
- 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。
- 支持 `宏``资历` 两类 30 秒两轮交互。
- 支持 `宏``资历` 两类 10 秒两轮交互,超时默认选择第一项
- 支持本地 SQLite 避雷记录的增删改查。
- 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。
- 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。
- JX3BOX 的 Node、Next2、CMS 请求统一封装,资历与交易行基础数据支持本地快照缓存和过期兜底。
- JX3BOX 的 Node、Next2、CMS 请求统一封装,交易行基础物品数据支持本地快照缓存和过期兜底。
- 内置 47 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。
## 数据来源
| 数据源 | 代码入口 | 主要用途 |
| --- | --- | --- |
| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py``core/event_push.py` | 游戏查询、沙盘据点数据及 WebSocket 实时事件 |
| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏、资历及交易行 |
| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py``core/event_push.py` | 游戏查询、资历分布、沙盘据点数据及 WebSocket 实时事件 |
| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏及交易行 |
| 本地 SQLite | `core/sqlite.py``core/bilei_data.py` | 心法别名、避雷记录、事件订阅和基础数据缓存 |
外部数据源的可用性、数据时效和字段结构均不由本插件控制。接口变更、网络异常、凭据权限不足或上游限流都可能导致查询失败。
@@ -117,7 +117,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
- 当前分发器按空白切分消息,因此角色名、物品名、备注等单个参数不能包含空格。
- 未识别的消息会被忽略;已识别的指令会停止继续传播给其他插件。
- 缺少必填参数、数字转换失败或执行异常时,入口层统一回复 `参数错误或执行失败`
- 未绑定区服时按指令表提供标准参数。绑定后可以省略 `server` 参数;显式填写完整参数时仍优先使用本次输入的区服。
- 未绑定区服时按指令表提供标准参数。绑定后可以省略 `server` 参数;显式填写完整参数时仍优先使用本次输入的区服。在区服参数位置填写 `全区` 时,会忽略会话绑定并向接口传入空区服以查询全区数据。
- 标准区服名和 WebUI 中配置的别名都可用于查询。对于区服后的可选参数,分发器通过当前有效区服目录和别名判断首个参数是显式区服还是后续参数。
### 会话区服绑定
@@ -128,7 +128,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `绑定区服 梦江南` | 将当前会话绑定到指定区服;支持区服别名 |
| `解绑区服` | 解除当前会话绑定 |
例如会话已经绑定 `梦江南` 后,`战绩 角色名 33` 等价于 `战绩 梦江南 角色名 33`;仍可发送 `战绩 唯我独尊 角色名 33` 临时查询其他区服。
例如会话已经绑定 `梦江南` 后,`战绩 角色名 33` 等价于 `战绩 梦江南 角色名 33`;仍可发送 `战绩 唯我独尊 角色名 33` 临时查询其他区服,或发送 `战绩 全区 角色名 33` 查询全区数据
## 指令参考
@@ -235,10 +235,10 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `资历排行 [服务器] [门派]` | 资历排行榜;图片 | Token + Ticket |
| `技能 心法` | 心法技能;图片 | Token + Ticket |
| `奇穴 心法` | 心法奇穴;图片 | Token + Ticket |
| `宏 心法` | 先返回宏列表,30 秒内回复序号后返回宏文本和帖子内容 | 无 |
| `资历 服务器 角色` | 返回 `0-18` 分类菜单,30 秒内回复序号后渲染资历进度图 | Token |
| `宏 心法` | 先返回动态宏列表,10 秒内回复序号后返回宏文本和帖子内容;超时默认选择热度第一项 | 无 |
| `资历 服务器 角色` | 返回固定资历分类菜单,10 秒内回复序号后通过 JX3API 渲染资历进度图 | Token + Ticket |
`资历``0` 表示总览,`1-18` 依次对应杂闻、武学、修为、装备、技艺、阅读、任务、足迹、战斗、声望、秘境、帮会、阵营、节日、活动、风雨江湖路、家园和剑侠录。第二轮只需回复数字,不需要再次添加插件前缀。
`资历``1` 表示总览,`2-19` 固定对应杂闻、武学、修为、装备、技艺、阅读、任务、足迹、战斗、声望、秘境、帮会、阵营、节日、活动、风雨江湖路、家园和剑侠录,不再使用本地资历菜单和点数缓存。第二轮只需回复数字,不需要再次添加插件前缀10 秒未选择时默认把首项 `{"1": "总览"}` 交给次轮查询并继续渲染
### 游戏社区与休闲
@@ -300,7 +300,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `事件推送 2001 关闭` | 为当前会话取消订阅事件 `2001` |
| `事件推送 列表` | 查看支持的全部事件编号 |
总开关和具体事件开关必须同时开启,消息才会发送到当前会话。事件正文包含 `server` 时,只向绑定该区服和未绑定区服的订阅会话发送;不含服务器的新闻、版本更新等事件不受区服过滤影响。免费事件为:`2001` 开服状态、`2002` 官方新闻、`2003` 版本更新、`2004` 八卦速报、`2005` 关隘首领、`2006`预告其他事件需要独立的事件版令牌。
总开关和具体事件开关必须同时开启,消息才会发送到当前会话。事件正文包含 `server` 时,只向绑定该区服和未绑定区服的订阅会话发送;不含服务器的新闻、版本更新等事件不受区服过滤影响。插件已覆盖 JX3API 当前 39 种 WebSocket 事件,并按各事件的官方字段输出可读中文消息。免费事件为:`2001` 开服状态、`2002` 官方新闻、`2003` 版本更新、`2004` 八卦速报、`2005` 关隘首领、`2006`预告其他事件需要独立的事件版令牌,可通过 `事件推送 列表` 查看全部编号
### WebUI 插件管理
@@ -344,7 +344,7 @@ flowchart LR
- `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。
- `server_aliases`:保存标准区服名及其 JSON 别名列表。
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
- `achievement_cache`JSON 基础数据缓存及更新时间。
- `trade_item_cache`JX3BOX 交易行基础物品数据缓存及更新时间。
`kungfu` 表首次创建时从 `data/kungfu.json` 幂等导入 32 条默认心法,`server_aliases` 表从 `data/server_aliases.json` 幂等导入默认区服别名;已存在的本地记录均不会被覆盖。随后启动 JX3API WebSocket 事件通道并建立指令映射。插件停用时会关闭事件通道、两个 HTTP Session 和本地 SQLite 连接。
@@ -364,8 +364,8 @@ flowchart LR
两个数据服务按上游来源拆分:
- `JX3APIService`:以 `https://www.jx3api.com` 为根地址,通过 GET 请求实现主体功能和沙盘据点查询。
- `JX3BOXService`:通过统一请求入口访问 JX3BOX 的 Node、CMS 和 Next2 服务,负责攻略、配装、宏、资历及交易行。
- `JX3APIService`:以 `https://www.jx3api.com` 为根地址,通过 GET 请求实现主体功能、资历分布和沙盘据点查询。
- `JX3BOXService`:通过统一请求入口访问 JX3BOX 的 Node、CMS 和 Next2 服务,负责攻略、配装、宏及交易行。
服务方法通常返回统一结构:
@@ -402,9 +402,9 @@ flowchart LR
- `plain_msg()`:纯文本。
- `T2I_image_msg()`:向模板注入业务数据和本地图标,再调用 AstrBot HTML 渲染器生成 JPEG。
- `image_msg()`:直接发送远程图片 URL 或图片数据。
- `plain_chain()`:发送文本与多张图片组成的消息链。
- `handler_plain_image_msg()`宏查询的两轮会话
- `handler_zili_msg()`:资历查询的两轮会话
- `plain_chain()`:发送文本与图片组成的富媒体消息链;结果包含 HTML 正文时,会将其渲染为图片组件追加到消息链。
- `handler_plain_image_msg()`通用两轮会话;首轮函数直接返回 `{"1": "总览", "2": "杂闻"}` 形式的数字键值对(无 `code``msg``data` 外层),处理器据此生成序号文本;选择后裁剪为只含所选项的新字典交给次轮函数,再由注入的输出器发送结果;超时固定选择第一项
- `zili()`:在指令入口直接调用上述通用两轮会话处理器,并通过标准图片流程输出结果
图片默认使用质量 `100`、完整页面截图和普通设备缩放级别。模板可通过 `icons.img``icons.sect``icons.serendipity` 访问通用、门派/心法和奇遇图标。
@@ -448,11 +448,11 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
| `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`
`trade_item_cache` 只保存 JX3BOX 交易行物品分组快照,当前键为 `trade_item_groups`。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。升级时会从旧 `achievement_cache` 迁移交易行缓存并删除旧表,历史资历菜单和点数缓存不会继续保留
### 7. 实时事件推送
`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。
`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。当前支持官方 `SocketEventMap` 中的全部 39 种事件,包含奇遇、马驹、扶摇、烟花、的卢、玄晶、阵营、宣战、据点、攻防拍卖与免费资讯事件。
每条消息按 `action` 识别事件,正文优先读取 `detail`,同时兼容 `data`。分发时联查会话区服绑定:正文包含 `server` 时仅保留同区服或未绑定区服的接收会话,正文不含 `server` 时不做区服过滤。之后只有会话的 `enabled=1` 且对应 `action_编号=1` 时才发送。会话 ID 直接取当前消息的 `unified_msg_origin`
@@ -525,10 +525,10 @@ git diff --check
## 当前版本状态
以下内容是对 v3.4.0 当前源码的静态核对结果,部署和二次开发前应注意:
以下内容是对 v3.4.1 当前源码的静态核对结果,部署和二次开发前应注意:
1. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`
2. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus``/api/node/achievement/points` 刷新
2. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces``seniority``score/totalScore` 渲染,JX3BOX 不再参与资历查询
3. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
4. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
+255 -66
View File
@@ -23,23 +23,24 @@ EVENT_NAMES = {
1001: "奇遇触发",
1002: "马驹刷新",
1003: "马驹捕获",
1004: "扶摇预告",
1005: "扶摇开启",
1006: "扶摇点名",
1008: "的卢每日",
1007: "烟花报时",
1008: "的卢预告",
1009: "的卢刷新",
1010: "的卢捕获",
1011: "的卢拍卖",
1012: "副本掉落 / 私货",
1012: "玄晶报时",
1013: "阵营拍卖",
1014: "诛恶事件",
1015: "追魂点名",
1017: "阵营祭",
1018: "关隘首领",
1016: "阵营祭天预告",
1017: "阵营祭天终点",
1101: "领地宣战·开始",
1102: "领地宣战·结束",
1103: "帮会宣战·开始",
1104: "帮会宣战·结束",
1105: "帮会约战·完胜",
1108: "帮会野外宣战·开始",
1109: "帮会野外宣战·结束",
1111: "抢占粮仓",
1112: "大旗重置",
1113: "大旗被夺",
@@ -52,16 +53,237 @@ EVENT_NAMES = {
1120: "小攻防分红",
1121: "大攻防分红",
1122: "大攻防分红(含指挥)",
1201: "微博更新",
2001: "开服状态",
2002: "官方新闻",
2003: "版本更新",
2004: "八卦速报",
2005: "关隘首领",
2006: "预告",
2006: "预告",
}
EVENT_ACTIONS = tuple(EVENT_NAMES)
SERVER_FIELDS = (
("大区", "zone", "text"),
("服务器", "server", "text"),
)
EVENT_FIELDS = {
1001: SERVER_FIELDS + (
("角色", "name", "text"),
("奇遇", "event", "text"),
("等级", "level", "text"),
("时间", "time", "time"),
),
1002: SERVER_FIELDS + (
("地图", "map_name", "text"),
("刷新时间", "time", "time"),
),
1003: SERVER_FIELDS + (
("名称", "name", "text"),
("地图", "map_name", "text"),
("马驹", "horse", "text"),
("等级", "level", "text"),
("捕获时间", "time", "time"),
),
1004: SERVER_FIELDS + (("预告时间", "time", "time"),),
1005: SERVER_FIELDS + (("开启时间", "time", "time"),),
1006: SERVER_FIELDS + (
("点名角色", "name", "list"),
("时间", "time", "time"),
),
1007: SERVER_FIELDS + (
("燃放者", "sender", "text"),
("接收者", "receiver", "text"),
("烟花", "firework", "text"),
("地图", "map_name", "text"),
("时间", "time", "time"),
),
1008: SERVER_FIELDS + (
("马驹", "name", "text"),
("地图", "map_name", "text"),
("预告时间", "time", "time"),
),
1009: SERVER_FIELDS + (
("马驹", "name", "text"),
("地图", "map_name", "text"),
("刷新时间", "refresh_time", "time"),
),
1010: SERVER_FIELDS + (
("马驹", "name", "text"),
("地图", "map_name", "text"),
("捕获角色", "capture_role_name", "text"),
("角色阵营", "capture_camp_name", "text"),
("捕获时间", "capture_time", "time"),
),
1011: SERVER_FIELDS + (
("马驹", "name", "text"),
("竞拍角色", "auction_role_name", "text"),
("角色阵营", "auction_camp_name", "text"),
("成交金额", "auction_amount", "text"),
("拍卖时间", "auction_time", "time"),
),
1012: SERVER_FIELDS + (
("角色", "role_name", "text"),
("副本", "map_name", "text"),
("物品", "item_name", "text"),
("时间", "time", "time"),
),
1013: SERVER_FIELDS + (
("竞拍角色", "role_name", "text"),
("阵营", "camp_name", "text"),
("物品", "item_name", "text"),
("成交金额", "item_amount", "text"),
("时间", "time", "time"),
),
1014: SERVER_FIELDS + (
("地图", "map_name", "text"),
("时间", "time", "time"),
),
1015: SERVER_FIELDS + (
("角色所在服", "role_server", "text"),
("点名角色", "role_name", "text"),
("时间", "time", "time"),
),
1016: SERVER_FIELDS + (("预告时间", "time", "time"),),
1017: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("帮会", "tong_name", "text"),
("角色", "role_name", "text"),
("据点", "castle_name", "text"),
("时间", "time", "time"),
),
1101: SERVER_FIELDS + (
("战场类型", "battlefield_type", "text"),
("宣战帮会", "declaring_tong_name", "text"),
("应战帮会", "accepting_tong_name", "text"),
("领地帮会", "battlefield_tong_name", "text"),
("开始时间", "start_time", "time"),
),
1102: SERVER_FIELDS + (
("战场类型", "battlefield_type", "text"),
("宣战帮会", "declaring_tong_name", "text"),
("应战帮会", "accepting_tong_name", "text"),
("领地帮会", "battlefield_tong_name", "text"),
("获胜帮会", "victory_tong_name", "text"),
("获胜积分", "victory_score", "text"),
("结束时间", "end_time", "time"),
),
1108: SERVER_FIELDS + (
("战场类型", "battlefield_type", "text"),
("宣战帮会", "declaring_tong_name", "text"),
("应战帮会", "accepting_tong_name", "text"),
("持续时长(小时)", "duration_hours", "text"),
("开始时间", "start_time", "time"),
),
1109: SERVER_FIELDS + (
("战场类型", "battlefield_type", "text"),
("宣战帮会", "declaring_tong_name", "text"),
("应战帮会", "accepting_tong_name", "text"),
("结束时间", "end_time", "time"),
),
1111: SERVER_FIELDS + (
("据点", "castle_name", "text"),
("阵营", "camp_name", "text"),
("时间", "time", "time"),
),
1112: SERVER_FIELDS + (
("据点", "castle_name", "text"),
("时间", "time", "time"),
),
1113: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("地图", "map_name", "text"),
("据点", "castle_name", "text"),
("时间", "time", "time"),
),
1114: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("帮会", "tong_name", "text"),
("据点", "castle_name", "text"),
("时间", "time", "time"),
),
1115: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("据点", "castle_name", "text"),
("时间", "time", "time"),
),
1116: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("贡献帮会", "tong_name", "list"),
("时间", "time", "time"),
),
1117: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("贡献帮会", "tong_name", "list"),
("时间", "time", "time"),
),
1118: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("贡献帮会", "tong_name", "list"),
("时间", "time", "time"),
),
1119: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("竞拍角色", "role_name", "text"),
("物品", "item_name", "text"),
("成交金额", "item_amount", "text"),
("时间", "time", "time"),
),
1120: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("分红帮会", "tong_name", "list"),
("分红金额", "split_amount", "text"),
("时间", "time", "time"),
),
1121: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("分红帮会", "tong_name", "list"),
("分红金额", "split_amount", "text"),
("时间", "time", "time"),
),
1122: SERVER_FIELDS + (
("阵营", "camp_name", "text"),
("指挥帮会", "chief_tong_name", "text"),
("分红帮会", "tong_name", "list"),
("分红金额", "split_amount", "text"),
("时间", "time", "time"),
),
2001: SERVER_FIELDS + (
("状态", "status", "status"),
("时间", "time", "time"),
),
2002: (
("类型", "type", "text"),
("标题", "title", "text"),
("日期", "date", "text"),
("链接", "url", "text"),
),
2003: (
("当前版本", "now_version", "text"),
("最新版本", "new_version", "text"),
("更新包数量", "package_num", "text"),
("更新大小", "package_size", "text"),
),
2004: (
("分类", "tags", "text"),
("服务器", "server", "text"),
("发布者", "name", "text"),
("标题", "title", "text"),
("日期", "date", "text"),
("链接", "url", "text"),
),
2005: (
("服务器", "server", "text"),
("关卡", "stage", "text"),
("开始时间", "start", "time"),
),
2006: (
("事件", "name", "text"),
("地点", "site", "text"),
("说明", "desc", "text"),
("时间", "time", "time"),
),
}
class EventPushService:
"""JX3API WebSocket 事件接收、会话订阅与消息分发。"""
@@ -424,63 +646,30 @@ class EventPushService:
return str(value or "未知")
def _format_event(self, action: int, detail: dict[str, Any]) -> str:
if action == 2001:
raw_status = detail.get("status")
server_status = {0: "维护", 1: "开服", "0": "维护", "1": "开服"}.get(
raw_status,
str(raw_status or "未知"),
)
return (
"【开服状态】\n"
f"大区:{detail.get('zone', '未知')}\n"
f"服务器:{detail.get('server', '未知')}\n"
f"状态:{server_status}\n"
f"时间:{self._format_timestamp(detail.get('time'))}"
)
if action == 2002:
return (
"【官方新闻】\n"
f"类型:{detail.get('type', '未知')}\n"
f"标题:{detail.get('title', '未知')}\n"
f"日期:{detail.get('date', '未知')}\n"
f"链接:{detail.get('url', '')}"
)
if action == 2003:
return (
"【版本更新】\n"
f"当前版本:{detail.get('now_version', '未知')}\n"
f"最新版本:{detail.get('new_version', '未知')}\n"
f"更新包数量:{detail.get('package_num', '未知')}\n"
f"更新大小:{detail.get('package_size', '未知')}"
)
if action == 2004:
return (
"【八卦速报】\n"
f"分类:{detail.get('tags', '未知')}\n"
f"区服:{detail.get('zone', '未知')} / {detail.get('server', '未知')}\n"
f"来源:{detail.get('tieba', '未知')}\n"
f"标题:{detail.get('title', '未知')}\n"
f"日期:{detail.get('date', '未知')}\n"
f"链接:{detail.get('url', '')}"
)
if action == 2005:
return (
"【关隘首领】\n"
f"服务器:{detail.get('server', '未知')}\n"
f"关卡:{detail.get('stage', '未知')}\n"
f"开始时间:{self._format_timestamp(detail.get('start'))}"
)
if action == 2006:
return (
"【云从预告】\n"
f"事件:{detail.get('name', '未知')}\n"
f"地点:{detail.get('site', '未知')}\n"
f"说明:{detail.get('desc', '')}\n"
f"时间:{self._format_timestamp(detail.get('time'))}"
)
fields = EVENT_FIELDS.get(action)
if not fields:
detail_text = json.dumps(detail, ensure_ascii=False, indent=2)
return f"【事件推送 · {action}\n{detail_text}"
detail_text = json.dumps(detail, ensure_ascii=False, indent=2)
return f"【事件推送 · {action} {EVENT_NAMES[action]}\n{detail_text}"
lines = [f"{EVENT_NAMES[action]}"]
for label, key, value_type in fields:
lines.append(f"{label}{self._format_field(detail.get(key), value_type)}")
return "\n".join(lines)
def _format_field(self, value: Any, value_type: str) -> str:
if value_type == "time":
return self._format_timestamp(value)
if value_type == "status":
if value in (0, "0"):
return "维护"
if value in (1, "1"):
return "开服"
return str(value if value is not None and value != "" else "未知")
if value_type == "list":
if isinstance(value, (list, tuple)):
values = [str(item).strip() for item in value if str(item).strip()]
return "".join(values) if values else ""
return str(value if value is not None and value != "" else "未知")
@staticmethod
def _usage_text() -> str:
@@ -503,4 +692,4 @@ class EventPushService:
for action in EVENT_ACTIONS
if action not in FREE_EVENT_ACTIONS
)
return f"免费事件:\n{free}\n\n事件版事件:\n{paid}"
return f"免费事件:\n{free}\n\n令牌事件:\n{paid}"
+56 -4
View File
@@ -1,7 +1,7 @@
import json
import html
import re
from datetime import datetime, timedelta
from datetime import datetime
from typing import Dict, Any, Optional, List, Union
from inspect import isawaitable
from typing import Any, Awaitable, Callable, Dict, Optional
@@ -19,15 +19,13 @@ from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,
class JX3APIService:
def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB, cache_sqlite: Optional[AsyncSQLiteDB] = None):
def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB):
# 实例化 API Client
self._api: APIClient = APIClient()
# 引用插件配置文件
self._config = config
# 引用sqlite
self._sql_db = sqlite
self._cache_db = cache_sqlite or sqlite
# 获取配置中的 Token
self.token = self._config.get("jx3api_token", "")
if self.token == "":
@@ -1067,6 +1065,60 @@ class JX3APIService:
)
async def zili_menu(self) -> Dict[str, str]:
"""资历选择内容。"""
return {
"1": "总览",
"2": "杂闻",
"3": "武学",
"4": "修为",
"5": "装备",
"6": "技艺",
"7": "阅读",
"8": "任务",
"9": "足迹",
"10": "战斗",
"11": "声望",
"12": "秘境",
"13": "帮会",
"14": "阵营",
"15": "节日",
"16": "活动",
"17": "风雨江湖路",
"18": "家园",
"19": "剑侠录"
}
async def zili(self,server: str,name: str,selected: Dict[str, Any],) -> Dict[str, Any]:
"""资历分布"""
if not isinstance(selected, dict) or len(selected) != 1:
return_data = self._init_return_data()
return_data["msg"] = "资历选项数据格式异常"
return return_data
selected_name = str(next(iter(selected.values())) or "").strip()
selected_subclass = "" if selected_name == "总览" else selected_name
async def processor(data: Any, return_data: Dict[str, Any]) -> None:
statistics = data.get("data") or {}
categories = statistics.get("total") or {}
if not isinstance(categories, dict) or not categories:
raise ValueError("接口未返回资历统计")
# 查询总览时 total 为“大类 -> 小类 -> 统计”;指定 subclass 后,
# JX3API 会去掉大类这一层,total 直接变成“小类 -> 统计”。
data["subclass"] = selected_subclass
return_data["data"] = data
return await self._request_api(
path="/tuilan/achievement",
params={"server": server,"name": name,"class": 1,"subclass": selected_subclass,"ticket": self.ticket,"token": self.token,},
processor=processor,
template="zili.html",
)
async def jueshe(self,server: str, name: str, history:int) -> Dict[str, Any]:
"""角色详情"""
async def processor(data: Any, return_data: Dict[str, Any]) -> None:
+82 -266
View File
@@ -14,30 +14,6 @@ from .request import APIClient
from .sqlite import AsyncSQLiteDB
from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining
ACHIEVEMENT_CHOICES = [
(0, None, "资历总览"),
(1, "1", "杂闻总览"),
(2, "2", "武学总览"),
(3, "3", "修为总览"),
(4, "4", "装备总览"),
(5, "5", "技艺总览"),
(6, "6", "阅读总览"),
(7, "7", "任务总览"),
(8, "8", "足迹总览"),
(9, "9", "战斗总览"),
(10, "10", "声望总览"),
(11, "11", "秘境总览"),
(12, "12", "帮会总览"),
(13, "13", "阵营总览"),
(14, "15", "节日总览"),
(15, "16", "活动总览"),
(16, "17", "风雨江湖路总览"),
(17, "40", "家园总览"),
(18, "41", "剑侠录总览"),
]
ACHIEVEMENT_CHOICE_MAP = {index: (menu_id, title) for index, menu_id, title in ACHIEVEMENT_CHOICES}
JX3BOX_API_BASE_URLS = {
"node": "https://node.jx3box.com",
"next2": "https://next2.jx3box.com",
@@ -226,11 +202,8 @@ class JX3BOXService:
return return_data
async def hong1(self, name: str) -> Dict[str, Any]:
"""宏 心法"""
return_data = self._init_return_data()
# 数据库查询数据
async def _macro_posts(self, name: str) -> Optional[List[Dict[str, Any]]]:
"""按心法查询宏帖子,并保留接口热度排序。"""
result = await self._sql_db.select_one(
"kungfu",
"name=? OR name1=? OR name2=? OR name3=? OR name4=? OR name5=?",
@@ -238,52 +211,91 @@ class JX3BOXService:
)
if result is None:
return_data["msg"] = "未找到该心法"
return return_data
return None
kungfu = result.get("name", None)
if kungfu is None:
return_data["msg"] = "未找到该心法"
return return_data
return None
logger.debug(f"查询到数据:{kungfu}")
# 1. 构造请求参数
params = {"subtype": kungfu}
# 2. 调用基础请求
data: Optional[Dict[str, Any]] = await self._base_request(
"cms",
"/api/cms/posts",
params=params,
params={"subtype": kungfu},
)
if not isinstance(data, dict):
return None
if not data:
return_data["msg"] = "未找到该心法一键宏"
return return_data
return [
item
for item in data.get("list", [])
if isinstance(item, dict) and item.get("ID")
]
async def hong1(self, name: str) -> Dict[str, str]:
"""宏首轮:返回“序号 -> 显示内容”的选择数据。"""
posts = await self._macro_posts(name)
if not posts:
raise ValueError("未找到该心法一键宏")
# 提取ID
pid_list = [0]
msg = "按照热度排列\n"
n = 1
try:
for m in data.get('list',[]):
msg += f"{n}{m['author']}\t{m['post_title']}\n"
pid_list.append(m["ID"])
n += 1
return_data["msg"] = msg
return_data["data"]["list"] = pid_list
return_data["data"]["num"] = n
options = {
str(index): f"{post.get('author', '')}\t{post.get('post_title', '')}"
for index, post in enumerate(posts, start=1)
}
if not options:
raise ValueError("未找到该心法一键宏")
except Exception as e:
logger.exception("处理返回数据失败")
return_data["msg"] = "处理返回数据失败"
return return_data
return_data["code"] = 200
if isinstance(e, ValueError):
raise
raise RuntimeError("处理返回数据失败") from e
return return_data
return options
async def hong_select(self,name: str,selected: Dict[str, Any],) -> Dict[str, Any]:
"""宏次轮:根据单项选择数据重新定位帖子并查询详情。"""
if not isinstance(selected, dict) or len(selected) != 1:
return_data = self._init_return_data()
return_data["msg"] = "宏选项数据格式异常"
return return_data
choice, label = next(iter(selected.items()))
posts = await self._macro_posts(name)
if not posts:
return_data = self._init_return_data()
return_data["msg"] = "未找到该心法一键宏"
return return_data
selected_post = next(
(
post
for post in posts
if f"{post.get('author', '')}\t{post.get('post_title', '')}"
== str(label)
),
None,
)
if selected_post is None:
try:
selected_post = posts[int(choice) - 1]
except (TypeError, ValueError, IndexError):
return_data = self._init_return_data()
return_data["msg"] = "无效宏序号"
return return_data
pid = str(selected_post.get("ID") or "").strip()
if not pid:
return_data = self._init_return_data()
return_data["msg"] = "宏帖子 ID 无效"
return return_data
return await self.hong2(pid)
async def hong2(self, pid: str) -> Dict[str, Any]:
@@ -328,12 +340,12 @@ class JX3BOXService:
return return_data
async def _load_achievement_cache(self, key: str) -> tuple[Optional[Any], bool]:
"""读取资历基础数据缓存,返回数据和是否已过期"""
async def _load_trade_cache(self, key: str) -> tuple[Optional[Any], bool]:
"""读取交易行基础数据缓存,返回数据和是否已过期"""
try:
row = await self._cache_db.select_one("achievement_cache", "key=?", (key,))
row = await self._cache_db.select_one("trade_item_cache", "key=?", (key,))
except Exception as e:
logger.error(f"读取资历缓存失败: {e}")
logger.error(f"读取交易行缓存失败: {e}")
return None, True
if not row:
@@ -345,16 +357,16 @@ class JX3BOXService:
expired = datetime.now() - updated_at > timedelta(days=30)
return payload, expired
except Exception as e:
logger.error(f"解析资历缓存失败: {e}")
logger.error(f"解析交易行缓存失败: {e}")
return None, True
async def _save_achievement_cache(self, key: str, payload: Any):
"""写入资历基础数据缓存"""
async def _save_trade_cache(self, key: str, payload: Any):
"""写入交易行基础数据缓存"""
try:
await self._cache_db.execute(
"""
INSERT INTO achievement_cache (key, content, updated_at)
INSERT INTO trade_item_cache (key, content, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
content=excluded.content,
@@ -367,31 +379,13 @@ class JX3BOXService:
),
)
except Exception as e:
logger.error(f"写入资历缓存失败: {e}")
async def _get_achievement_base_data(self, cache_key: str, api_path: str) -> Optional[Dict[str, Any]]:
"""获取资历菜单或点数数据,优先使用未过期缓存"""
cached, expired = await self._load_achievement_cache(cache_key)
if cached and not expired:
return cached
data: Optional[Dict[str, Any]] = await self._base_request("node", api_path)
if data and isinstance(data, dict):
await self._save_achievement_cache(cache_key, data)
return data
if cached:
logger.warning(f"资历基础数据接口失败,使用旧缓存: {cache_key}")
return cached
return None
logger.error(f"写入交易行缓存失败: {e}")
async def _get_trade_item_groups(self) -> Optional[List[Dict[str, Any]]]:
"""获取交易行物品库,优先使用未过期缓存"""
cache_key = "trade_item_groups"
cached, expired = await self._load_achievement_cache(cache_key)
cached, expired = await self._load_trade_cache(cache_key)
if isinstance(cached, list) and not expired:
return cached
@@ -402,7 +396,7 @@ class JX3BOXService:
params={"client": "std"},
)
if isinstance(data, list) and data:
await self._save_achievement_cache(cache_key, data)
await self._save_trade_cache(cache_key, data)
return data
if isinstance(cached, list) and cached:
@@ -461,184 +455,6 @@ class JX3BOXService:
return [row[3] for row in matched[:limit]]
def _flatten_achievement_ids(self, values: Any) -> list[int]:
"""展开菜单中的单个资历 ID 和数组资历 ID"""
result = []
if isinstance(values, list):
for item in values:
result.extend(self._flatten_achievement_ids(item))
return result
try:
result.append(int(values))
except (TypeError, ValueError):
pass
return result
def _category_achievement_ids(self, category: Dict[str, Any]) -> list[int]:
"""提取分类及其子分类包含的资历 ID"""
ids = self._flatten_achievement_ids(category.get("achievements", []))
for child in category.get("children", []) or []:
if isinstance(child, dict):
ids.extend(self._flatten_achievement_ids(child.get("achievements", [])))
return ids
def _build_achievement_progress(
self,
name: str,
achievement_ids: list[int],
point_map: Dict[str, Any],
completed_ids: set[int],
) -> Dict[str, Any]:
"""按资历点数计算完成进度"""
unique_ids = set(achievement_ids)
total_points = 0
completed_points = 0
completed_count = 0
for achievement_id in unique_ids:
try:
point = int(point_map.get(str(achievement_id), 0) or 0)
except (TypeError, ValueError):
point = 0
total_points += point
if achievement_id in completed_ids:
completed_count += 1
completed_points += point
percent = round(completed_points / total_points * 100, 2) if total_points else 0
return {
"name": name,
"total_points": total_points,
"completed_points": completed_points,
"percent": percent,
"percent_text": f"{percent:.2f}%",
"achievement_count": len(unique_ids),
"completed_count": completed_count,
}
async def zili(self, name: str, server: str, choice: int) -> Dict[str, Any]:
"""角色资历"""
return_data = self._init_return_data()
if choice not in ACHIEVEMENT_CHOICE_MAP:
return_data["msg"] = "无效序号,结束会话"
return return_data
role_params = {"server": server, "name": name, "token": self.token}
api_url = "https://www.jx3api.com/role/detail"
role_data: Optional[Dict[str, Any]] = await self._api.get(api_url, params=role_params, out_key="data")
if not role_data or not isinstance(role_data, dict):
return_data["msg"] = "未查询到角色"
return return_data
global_id = role_data.get("globalId")
if not global_id:
return_data["msg"] = "无法获取角色全区 ID"
return return_data
achievement_data = await self._base_request(
"next2",
"/api/next2/user-achievements",
params={"jx3id": global_id},
)
if not achievement_data or not isinstance(achievement_data, dict):
return_data["msg"] = "未查询到资历数据"
return return_data
completed_ids = set()
achievements = achievement_data.get("achievements") or ""
if isinstance(achievements, str):
for item in achievements.split(","):
try:
completed_ids.add(int(item.strip()))
except ValueError:
continue
elif isinstance(achievements, list):
completed_ids = set(self._flatten_achievement_ids(achievements))
menu_payload = await self._get_achievement_base_data(
"achievement_menus",
"/api/node/achievement/menus",
)
point_payload = await self._get_achievement_base_data(
"achievement_points",
"/api/node/achievement/points",
)
if not menu_payload or not point_payload:
return_data["msg"] = "基础资历数据获取失败"
return return_data
menus = menu_payload.get("menus", {})
points = point_payload.get("points", {})
if not isinstance(menus, dict) or not isinstance(points, dict):
return_data["msg"] = "基础资历数据格式异常"
return return_data
selected_menu_id, selected_title = ACHIEVEMENT_CHOICE_MAP[choice]
items = []
if choice == 0:
total_ids = []
for _, menu_id, title in ACHIEVEMENT_CHOICES[1:]:
category = menus.get(str(menu_id), {})
if not isinstance(category, dict):
continue
category_ids = self._category_achievement_ids(category)
total_ids.extend(category_ids)
items.append(
self._build_achievement_progress(title, category_ids, points, completed_ids)
)
summary = self._build_achievement_progress(selected_title, total_ids, points, completed_ids)
else:
category = menus.get(str(selected_menu_id), {})
if not isinstance(category, dict) or not category:
return_data["msg"] = "未找到该资历分类"
return return_data
category_ids = self._category_achievement_ids(category)
summary = self._build_achievement_progress(selected_title, category_ids, points, completed_ids)
for child in category.get("children", []) or []:
if not isinstance(child, dict):
continue
child_ids = self._flatten_achievement_ids(child.get("achievements", []))
items.append(
self._build_achievement_progress(child.get("name", "未命名"), child_ids, points, completed_ids)
)
try:
return_data["temp"] = await load_template("zili.html")
except FileNotFoundError as e:
logger.error(f"加载模板失败: {e}")
return_data["msg"] = "系统错误:模板文件不存在"
return return_data
return_data["data"] = {
"title": selected_title,
"summary": summary,
"items": items,
"role_name": role_data.get("roleName", name),
"server": role_data.get("serverName", server),
"zone": role_data.get("zoneName", ""),
"force_name": role_data.get("forceName", ""),
"camp_name": role_data.get("campName", ""),
"tong_name": role_data.get("tongName", ""),
"updated_at": achievement_data.get("updated_at", ""),
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
return_data["code"] = 200
return return_data
async def jiaoyihang(self, name: str , server: str) -> Dict[str, Any]:
"""区服交易行"""
return_data = self._init_return_data()
+79 -126
View File
@@ -1,3 +1,5 @@
from typing import Any
from astrbot.core import html_renderer
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -14,29 +16,6 @@ from .bilei_data import BiLeidata
class MessageBuilder:
"""回复消息构建"""
ZILI_MENU_TEXT = (
"请选择资历查询类型:\n"
"0:资历总览\n"
"1:杂闻总览\n"
"2:武学总览\n"
"3:修为总览\n"
"4:装备总览\n"
"5:技艺总览\n"
"6:阅读总览\n"
"7:任务总览\n"
"8:足迹总览\n"
"9:战斗总览\n"
"10:声望总览\n"
"11:秘境总览\n"
"12:帮会总览\n"
"13:阵营总览\n"
"14:节日总览\n"
"15:活动总览\n"
"16:风雨江湖路总览\n"
"17:家园总览\n"
"18:剑侠录总览"
)
def __init__(self,
jx3api: JX3APIService,
jx3box: JX3BOXService,
@@ -138,94 +117,64 @@ class MessageBuilder:
await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def handler_plain_image_msg(self, event: AstrMessageEvent, action1, action2):
"""两轮会话消息发送通用,先文本列表等反馈序号在发送图片"""
# 会话触发
async def plain_image_msg(self, event: AstrMessageEvent, action):
"""发送正文文本,并把可选 HTML 正文渲染为附图。"""
try:
# 获取一轮数据
data = await action1()
if data["code"] == 200:
# 发送一轮消息
await event.send(event.plain_result(data["msg"]))
# 获取触发用户ID
user_id = event.get_sender_id()
# 二轮会话流程
@session_waiter(timeout=30)
async def macro_select_waiter(controller: SessionController,new_event: AstrMessageEvent):
# 跳过非触发用户消息
if new_event.get_sender_id() != user_id:
return
# 获取用户消息
msg = new_event.get_message_str().strip()
# 判断消息是否为数字
if not msg.isdigit():
await new_event.send(
MessageChain().message("输入异常,结束会话")
)
controller.stop()
return
# 判断数字是否在有效值内
num = int(msg)
if num < 1 or num > data["data"]["num"]:
await new_event.send(
MessageChain().message("无效序号,结束会话")
)
controller.stop()
return
# 获取二轮数据
try:
data1 = await action2(data["data"]["list"][num])
if data1["code"] != 200:
await new_event.send(
MessageChain().message("获取详细数据失败")
)
controller.stop()
return
# 消息拼接发送
chain = MessageChain()
msg_text = data1["data"]
chain.message(msg_text)
if data1["temp"] != "":
url = await self.html_render(data1["temp"], {}, options={})
chain.url_image(url)
await new_event.send(chain)
except Exception as e:
logger.error(f"功能函数执行错误: {e}")
await new_event.send(
MessageChain().message("猪脑过载,请稍后再试")
)
controller.stop()
# 二轮会话激活
try:
await macro_select_waiter(event)
except TimeoutError:
await event.send(event.plain_result("选择超时,已结束会话"))
except Exception:
logger.error("选择发生异常", exc_info=True)
else:
await event.send(event.plain_result(f"未搜索到相关内容"))
data = await action()
if data.get("code") != 200:
await event.send(
event.plain_result(data.get("msg") or "获取详细数据失败")
)
return
chain = MessageChain()
if data.get("data"):
chain.message(str(data["data"]))
if data.get("temp"):
url = await self.html_render(data["temp"], {}, options={})
chain.url_image(url)
await event.send(chain)
except Exception as e:
logger.error(f"功能函数执行错误: {e}")
await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def handler_zili_msg(self, event: AstrMessageEvent, name: str, server: str):
"""资历查询专用两轮会话,第一轮文本菜单,第二轮图片"""
async def handler_plain_image_msg(self, event: AstrMessageEvent, action1, action2, result_handler=None, timeout: int = 10):
"""通用两轮会话:首轮数字键值对裁剪后交给次轮函数。"""
try:
await event.send(event.plain_result(self.ZILI_MENU_TEXT))
user_id = event.get_sender_id()
option_map = await action1()
if not isinstance(option_map, dict) or not option_map:
await event.send(event.plain_result("未搜索到可选内容"))
return
@session_waiter(timeout=30)
async def zili_select_waiter(controller: SessionController, new_event: AstrMessageEvent):
options = {str(key): value for key, value in option_map.items()}
if not all(key.isdigit() for key in options):
await event.send(event.plain_result("选项序号格式异常"))
return
menu_lines = ["请选择序号"]
menu_lines.extend(
f"{key}{label}"
for key, label in options.items()
)
await event.send(event.plain_result("\n".join(menu_lines)))
user_id = event.get_sender_id()
send_result = result_handler or self.T2I_image_msg
async def send_selected(
target_event: AstrMessageEvent,
selected: dict[str, Any],
):
await send_result(
target_event,
lambda: action2(selected),
)
@session_waiter(timeout=timeout)
async def option_select_waiter(
controller: SessionController,
new_event: AstrMessageEvent,
):
if new_event.get_sender_id() != user_id:
return
@@ -236,43 +185,38 @@ class MessageBuilder:
return
choice = int(msg)
if choice < 0 or choice > 18:
choice_key = str(choice)
if choice_key not in options:
await new_event.send(MessageChain().message("无效序号,结束会话"))
controller.stop()
return
try:
data = await self.jx3box.zili(name, server, choice)
if data["code"] != 200:
await new_event.send(MessageChain().message(data.get("msg", "获取资历数据失败")))
controller.stop()
return
options = {
"quality": 100,
"device_scale_factor_level": "normal",
"full_page": True,
"omit_background": False,
"type": "jpeg"
}
data["data"]["icons"] = self.icons
url = await self.html_render(data["temp"], data["data"], options=options)
await new_event.send(new_event.image_result(url))
await send_selected(
new_event,
{choice_key: options[choice_key]},
)
except Exception as e:
logger.error(f"资历查询执行错误: {e}")
logger.error(f"二轮查询执行错误: {e}")
await new_event.send(MessageChain().message("猪脑过载,请稍后再试"))
controller.stop()
try:
await zili_select_waiter(event)
await option_select_waiter(event)
except TimeoutError:
await event.send(event.plain_result("选择超时,已结束会话"))
first_key = next(iter(options))
await send_selected(
event,
{first_key: options[first_key]},
)
except Exception:
logger.error("资历选择发生异常", exc_info=True)
logger.error("二轮选择发生异常", exc_info=True)
except (ValueError, RuntimeError) as e:
await event.send(event.plain_result(str(e) or "未搜索到可选内容"))
except Exception as e:
logger.error(f"资历会话执行错误: {e}")
logger.error(f"二轮会话执行错误: {e}")
await event.send(event.plain_result("猪脑过载,请稍后再试"))
@@ -666,11 +610,20 @@ class MessageBuilder:
async def hong(self, event: AstrMessageEvent,name: str):
""" 宏 心法"""
return await self.handler_plain_image_msg(event, lambda: self.jx3box.hong1(name), self.jx3box.hong2)
return await self.handler_plain_image_msg(
event,
lambda: self.jx3box.hong1(name),
lambda selected: self.jx3box.hong_select(name, selected),
result_handler=self.plain_image_msg,
)
async def zili(self, event: AstrMessageEvent, server: str, name: str):
""" 资历 角色名称 服务器"""
return await self.handler_zili_msg(event, name, server)
return await self.handler_plain_image_msg(
event,
lambda: self.jx3api.zili_menu(),
lambda selected: self.jx3api.zili(server, name, selected),
)
async def jiaoyihang(self, event: AstrMessageEvent,server: str, Name: str):
""" 交易行 物品名称 服务器"""
+12
View File
@@ -8,6 +8,8 @@ from .sqlite import AsyncSQLiteDB
class ServerBindingService:
"""维护会话区服绑定、区服别名与指令解析所需的区服目录。"""
ALL_SERVERS_KEYWORD = "全区"
def __init__(self, sqlite: AsyncSQLiteDB, seed_path: Path):
self.sql = sqlite
self.seed_path = seed_path
@@ -103,6 +105,16 @@ class ServerBindingService:
server = self._clean(value)
return self._server_lookup.get(self._key(server), server)
def is_all_servers_query(self, value: Any) -> bool:
"""判断指令中的区服参数是否要求查询全区。"""
return self._key(value) == self._key(self.ALL_SERVERS_KEYWORD)
def resolve_query_server(self, value: Any) -> str:
"""解析查询区服;“全区”作为保留值转换为接口所需的空字符串。"""
if self.is_all_servers_query(value):
return ""
return self.resolve_server(value)
async def get_binding(self, session_id: str) -> str:
row = await self.sql.select_one(
"session_server_bindings",
+30 -10
View File
@@ -26,7 +26,7 @@ PLUGIN_NAME = "astrbot_plugin_jx3"
@register("astrbot_plugin_jx3",
"fxdyz",
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
"3.4.0",
"3.4.1",
"https://github.com/qsc20001102/astrbot_plugin_jx3"
)
class Jx3ApiPlugin(Star):
@@ -63,7 +63,7 @@ class Jx3ApiPlugin(Star):
try:
# 数据库初始化
await self.init_bilei_data()
await self.init_achievement_cache_data()
await self.init_trade_item_cache_data()
await self.kungfu_alias.initialize()
await self.server_binding.initialize()
@@ -155,7 +155,7 @@ class Jx3ApiPlugin(Star):
self.local_sql_db = AsyncSQLiteDB(str(self.local_data_path))
# 剑网三功能实例化
self.bilei = BiLeidata(self.local_sql_db)
self.jx3api = JX3APIService(self.conf, self.local_sql_db, self.local_sql_db)
self.jx3api = JX3APIService(self.conf, 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,
@@ -196,16 +196,33 @@ class Jx3ApiPlugin(Star):
""")
async def init_achievement_cache_data(self):
"""初始化资历基础数据缓存表"""
async def init_trade_item_cache_data(self):
"""初始化交易行物品缓存,并清理已停用的资历缓存表"""
await self.local_sql_db.execute("""
CREATE TABLE IF NOT EXISTS achievement_cache(
CREATE TABLE IF NOT EXISTS trade_item_cache(
key TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
legacy_cache = await self.local_sql_db.fetch_one(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
("achievement_cache",),
)
if legacy_cache:
# 旧表曾与交易行共用;仅迁移仍有效的交易行缓存后移除资历缓存。
await self.local_sql_db.execute(
"""
INSERT OR IGNORE INTO trade_item_cache (key, content, updated_at)
SELECT key, content, updated_at
FROM achievement_cache
WHERE key = ?
""",
("trade_item_groups",),
)
await self.local_sql_db.execute("DROP TABLE achievement_cache")
def ini_command_map(self):
"""初始化指令集"""
@@ -373,7 +390,7 @@ class Jx3ApiPlugin(Star):
event: AstrMessageEvent,
args: list[str],
) -> list[str]:
"""为带 server 参数的指令补齐会话绑定,并解析区服别名。"""
"""补齐会话绑定解析区服别名,并支持用“全区”显式传空区服"""
params = [
parameter
for parameter in inspect.signature(handler).parameters.values()
@@ -404,7 +421,10 @@ class Jx3ApiPlugin(Star):
explicit_server = (
has_server_arg
and (
self.server_binding.is_known_server(prepared[server_index])
self.server_binding.is_all_servers_query(
prepared[server_index]
)
or self.server_binding.is_known_server(prepared[server_index])
or len(prepared) >= len(params)
or (
has_required_parameter_after_server
@@ -413,13 +433,13 @@ class Jx3ApiPlugin(Star):
)
)
if explicit_server:
prepared[server_index] = self.server_binding.resolve_server(
prepared[server_index] = self.server_binding.resolve_query_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] = self.server_binding.resolve_query_server(
prepared[server_index]
)
+1 -1
View File
@@ -1,7 +1,7 @@
name: astrbot_plugin_jx3
display_name: 剑网三游戏数据查询工具
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
version: 3.4.0
version: 3.4.1
author: 飞翔大野猪
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
astrbot_version: ">=4.24.1"
+6 -6
View File
@@ -68,12 +68,12 @@ input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srg
.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); }
table { width: 100%; table-layout: auto; border-collapse: collapse; }
th, td { padding: 15px 20px; text-align: center; vertical-align: middle; 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; }
.actions { 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); }
@@ -82,7 +82,7 @@ tbody tr:hover { background: color-mix(in srgb, var(--accent-soft) 42%, transpar
.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; }
.tags { display: flex; justify-content: center; flex-wrap: wrap; gap: 7px; margin: 0 auto; }
.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; }
@@ -103,9 +103,9 @@ tbody tr:hover { background: color-mix(in srgb, var(--accent-soft) 42%, transpar
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 { display: grid; grid-template-columns: max-content 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.actions { grid-template-columns: max-content auto auto; align-items: center; width: 100%; white-space: normal; }
td.empty { display: block; padding: 36px 16px; }
td.empty::before { content: none; }
}
+3 -3
View File
@@ -20,10 +20,10 @@
<tbody>
{% for item in items %}
<tr>
<td class="data-cell--name data-cell--wide data-cell--max-wide data-cell--wrap">{{ item.declaringName }}</td>
<td class="data-cell--name data-cell--wide data-cell--max-wide data-cell--wrap">{{ item.acceptingName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.declaringName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.acceptingName }}</td>
<td class="data-cell--time">{{ item.startTime }}</td>
<td class="data-cell--score data-cell--medium">{{ item.durationSeconds }}</td>
<td class="data-cell--score">{{ item.durationSeconds }}</td>
<td class="data-cell--time">{{ item.endTime }}</td>
</tr>
{% endfor %}
+4 -4
View File
@@ -20,10 +20,10 @@
<tbody>
{% for item in items %}
<tr>
<td class="data-cell--accent data-cell--medium data-cell--nowrap">{{ item.itemName }}</td>
<td class="data-cell--center data-cell--compact data-cell--nowrap">{{ item.server }}</td>
<td class="data-cell--max-wide data-cell--wrap">{{ item.mapName }}</td>
<td class="data-cell--name data-cell--max-wide data-cell--wrap">{{ item.roleName }}</td>
<td class="data-cell--accent data-cell--nowrap">{{ item.itemName }}</td>
<td class="data-cell--nowrap">{{ item.server }}</td>
<td class="data-cell--wrap">{{ item.mapName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.roleName }}</td>
<td class="data-cell--time">{{ item.time }}</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -1,10 +1,11 @@
{# template-title: 副本奖励记录 #}
{# template-components: data-table #}
<div class="container">
<h1>副本奖励记录</h1>
<div class="description">
<div class="data-page__meta">
灰色表示奖励已经获取,绿色表示奖励尚未获取
</div>
@@ -21,7 +22,7 @@
</div>
{% if list %}
<table>
<table class="data-table data-table--spaced">
<thead>
<tr>
<th>副本名称</th>
@@ -81,4 +82,3 @@
</div>
{% endif %}
</div>
+3 -3
View File
@@ -30,9 +30,9 @@
class="camp-haoqi"
{% endif %}
>
<td class="data-cell--camp data-cell--compact data-cell--center data-cell--nowrap data-cell--strong">{{ item.camp_name }}</td>
<td class="data-cell--camp data-cell--name data-cell--medium data-cell--nowrap">{{ item.castle }}</td>
<td class="data-cell--compact data-cell--center data-cell--nowrap data-cell--strong">{{ item.str_status }}</td>
<td class="data-cell--camp data-cell--nowrap data-cell--strong">{{ item.camp_name }}</td>
<td class="data-cell--camp data-cell--name data-cell--nowrap">{{ item.castle }}</td>
<td class="data-cell--nowrap data-cell--strong">{{ item.str_status }}</td>
<td class="data-cell--time">{{ item.start_time }}</td>
<td class="data-cell--time">{{ item.end_time }}</td>
<td class="data-cell--time data-cell--accent">{{ item.remaining_time }}</td>
+1 -1
View File
@@ -206,6 +206,6 @@
</main>
<footer class="footer help-footer">
指令与参数之间使用空格分隔,带有 <strong>[ ]</strong> 的参数可以省略。
指令与参数之间使用空格分隔,带有 <strong>[ ]</strong> 的参数可以省略;会话绑定区服后可省略区服参数,在区服位置填写“全区”可查询全区数据
</footer>
</div>
+7 -6
View File
@@ -1,4 +1,5 @@
{# template-title: 剑网3交易行价格查询 #}
{# template-components: data-table #}
<div class="container">
<div class="header">
@@ -11,20 +12,20 @@
</div>
<div class="table-wrapper">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th style="width: 44%;">物品</th>
<th style="width: 24%;">物价</th>
<th style="width: 12%;">数量</th>
<th style="width: 20%;">数据时间</th>
<th>物品</th>
<th>物价</th>
<th>数量</th>
<th>数据时间</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr>
<td>
<div class="item">
<div class="trade-item icon-label">
<img class="item-icon" src="{{ item.icon }}" alt="">
<div class="item-name">{{ item.name }}</div>
</div>
+1 -1
View File
@@ -36,7 +36,7 @@
<tr>
<td class="data-cell--name data-cell--wrap">{{ item.szSkillName }}</td>
<td class="data-cell--wrap">{{ item.szBossName }}</td>
<td class="data-cell--center data-cell--nowrap data-cell--strong">{{ item.skill_color_text }}</td>
<td class="data-cell--nowrap data-cell--strong">{{ item.skill_color_text }}</td>
<td class="data-cell--score">{{ item.nLevel }}</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -1,14 +1,15 @@
{# template-title: 聊天记录 #}
{# template-components: data-table #}
<div class="container">
<h1>聊天记录</h1>
<div class="meta">
<div class="data-page__meta">
共查询到 {{ total | default(0, true) }} 条聊天记录
</div>
<table>
<table class="data-table data-table--spaced">
<thead>
<tr>
<th class="role-col">角色</th>
@@ -74,4 +75,3 @@
</tbody>
</table>
</div>
+4 -4
View File
@@ -31,10 +31,10 @@
<span>{{ item.forceName }}</span>
</div>
</td>
<td class="data-cell--name data-cell--medium data-cell--nowrap">{{ item.name }}</td>
<td class="data-cell--center data-cell--nowrap">{{ item.level }}</td>
<td class="data-cell--center data-cell--nowrap">{{ item.campName }}</td>
<td class="data-cell--max-wide data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--nowrap">{{ item.name }}</td>
<td class="data-cell--nowrap">{{ item.level }}</td>
<td class="data-cell--nowrap">{{ item.campName }}</td>
<td class="data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--score">{{ item.totalScore }}</td>
</tr>
{% endfor %}
+4 -4
View File
@@ -22,10 +22,10 @@
{% for item in items %}
<tr>
<td class="data-cell--rank">{{ loop.index }}</td>
<td class="data-cell--name data-cell--xwide data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wide data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--muted data-cell--medium data-cell--wrap">{{ item.castleName }}</td>
<td class="data-cell--center data-cell--nowrap data-cell--numeric">{{ item.memberCount }}/{{ item.maxMemberCount }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--muted data-cell--wrap">{{ item.castleName }}</td>
<td class="data-cell--nowrap data-cell--numeric">{{ item.memberCount }}/{{ item.maxMemberCount }}</td>
<td class="data-cell--score">{{ item.titlePoint }}</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -21,9 +21,9 @@
{% for item in items %}
<tr>
<td class="data-cell--rank">{{ loop.index }}</td>
<td class="data-cell--name data-cell--xwide data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wide data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--center data-cell--nowrap data-cell--numeric">{{ item.maxLimit }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--nowrap data-cell--numeric">{{ item.maxLimit }}</td>
<td class="data-cell--score">{{ item.totalScore }}</td>
</tr>
{% endfor %}
+2 -2
View File
@@ -20,8 +20,8 @@
{% for item in items %}
<tr>
<td class="data-cell--rank">{{ loop.index }}</td>
<td class="data-cell--name data-cell--xwide data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wide data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.tongName }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.masterName }}</td>
<td class="data-cell--score">{{ item.key }}</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -21,9 +21,9 @@
{% for item in items %}
<tr>
<td class="data-cell--rank">{{ loop.index }}</td>
<td class="data-cell--name data-cell--wide">{{ item.role_name }}</td>
<td class="data-cell--center data-cell--nowrap data-cell--numeric">{{ item.equip_score }}</td>
<td class="data-cell--center data-cell--nowrap data-cell--numeric">{{ item.max_level }}</td>
<td class="data-cell--name">{{ item.role_name }}</td>
<td class="data-cell--nowrap data-cell--numeric">{{ item.equip_score }}</td>
<td class="data-cell--nowrap data-cell--numeric">{{ item.max_level }}</td>
<td class="data-cell--score">{{ item.total_score }}</td>
</tr>
{% endfor %}
+4 -4
View File
@@ -26,7 +26,7 @@
<tbody>
{% for item in items %}
<tr>
<td class="data-cell--medium">
<td>
<div class="icon-label">
{% if icons.sect.get(item.forceName) %}
<img class="icon-label__icon" src="{{ icons.sect[item.forceName] }}" alt="{{ item.forceName }}">
@@ -35,10 +35,10 @@
</div>
</td>
<td class="data-cell--name data-cell--nowrap">{{ item.roleName }}</td>
<td class="data-cell--center data-cell--nowrap">{{ item.roleLevel }}</td>
<td class="data-cell--center data-cell--nowrap">{{ item.bodyName }}</td>
<td class="data-cell--nowrap">{{ item.roleLevel }}</td>
<td class="data-cell--nowrap">{{ item.bodyName }}</td>
<td>{{ item.tongName }}</td>
<td class="data-cell--center data-cell--nowrap">{{ item.campName }}</td>
<td class="data-cell--nowrap">{{ item.campName }}</td>
<td class="data-cell--copy">{{ item.comment }}</td>
</tr>
{% endfor %}
+8 -7
View File
@@ -1,4 +1,5 @@
{# template-title: {{ name }} - 万宝楼行情 #}
{# template-components: data-table #}
{#
@@ -70,7 +71,7 @@
<span class="price">参考价格:{{ retail }} 元</span>
</div>
<div class="table-wrapper">
<div class="market-table-grid">
<!-- ==================================================
1. 万宝楼公示
@@ -81,7 +82,7 @@
<div class="table-title">万宝楼公示</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
@@ -139,7 +140,7 @@
<div class="table-title">万宝楼在售</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
@@ -202,7 +203,7 @@
</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
@@ -273,7 +274,7 @@
<div class="table-title">电信区</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
@@ -344,7 +345,7 @@
<div class="table-title">双线区</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
@@ -415,7 +416,7 @@
<div class="table-title">无界区</div>
<div class="table-scroll">
<table>
<table class="data-table data-table--nowrap">
<thead>
<tr>
<th>时间</th>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="container data-page">
<h1>心法小药表</h1>
<table class="data-table data-table--center">
<table class="data-table">
<thead>
<tr>
<th rowspan="2">心法</th>
+2 -1
View File
@@ -1,4 +1,5 @@
{# template-title: 剑网三 战绩面板 #}
{# template-components: data-table #}
<div class="panel">
@@ -49,7 +50,7 @@
</div>
<!-- 对战记录表 -->
<table>
<table class="data-table data-table--spaced">
<thead>
<tr>
<th class="time-col">开始时间</th>
+3 -3
View File
@@ -26,9 +26,9 @@
class="camp-haoqi"
{% endif %}
>
<td class="data-cell--camp data-cell--compact data-cell--center data-cell--nowrap data-cell--strong">{{ item.campName }}</td>
<td class="data-cell--medium data-cell--center data-cell--nowrap data-cell--strong">{{ item.fenxianName }}</td>
<td class="data-cell--medium data-cell--center data-cell--nowrap data-cell--strong">{{ item.friendName }}</td>
<td class="data-cell--camp data-cell--nowrap data-cell--strong">{{ item.campName }}</td>
<td class="data-cell--nowrap data-cell--strong">{{ item.fenxianName }}</td>
<td class="data-cell--nowrap data-cell--strong">{{ item.friendName }}</td>
<td class="data-cell--name data-cell--copy">{{ item.roleName }}</td>
<td class="data-cell--time">{{ item.seizeTime }}</td>
</tr>
+1 -1
View File
@@ -18,7 +18,7 @@
<tbody>
{% for item in items %}
<tr>
<td class="data-cell--accent data-cell--wide data-cell--center data-cell--nowrap">{{ item.server }}</td>
<td class="data-cell--accent data-cell--nowrap">{{ item.server }}</td>
<td class="data-cell--name data-cell--wrap">{{ item.mapName }}</td>
<td class="data-cell--time">{{ item.time }}</td>
</tr>
+83 -35
View File
@@ -1,53 +1,101 @@
{# template-title: 资历查询 #}
{% macro progress_card(css_class, name, completed_points, total_points, completed_count, achievement_count) %}
{% set percent = (completed_points * 100 / total_points) if total_points else 0 %}
<div class="{{ css_class }}">
<div class="{{ 'summary-top' if css_class == 'summary' else 'item-top' }}">
<div class="{{ 'summary-name' if css_class == 'summary' else 'item-name' }}">{{ name }}</div>
<div class="{{ 'summary-percent' if css_class == 'summary' else 'item-percent' }}">{{ "%.2f"|format(percent) }}%</div>
</div>
<div class="score">
{{ completed_points }} / {{ total_points }} 点
· {{ completed_count }} / {{ achievement_count }} 项
</div>
<div class="progress">
<div class="progress-fill" style="width: {{ percent }}%;"></div>
</div>
</div>
{% endmacro %}
{% set page_title = (subclass ~ '总览') if subclass else '资历总览' %}
{% set selected_items = data.total if subclass else {} %}
{% set summary = namespace(pieces_total=0, pieces_speed=0, seniority_total=0, seniority_speed=0) %}
{% if subclass %}
{% for stats in selected_items.values() %}
{% set summary.pieces_total = summary.pieces_total + stats.pieces.total %}
{% set summary.pieces_speed = summary.pieces_speed + stats.pieces.speed %}
{% set summary.seniority_total = summary.seniority_total + stats.seniority.total %}
{% set summary.seniority_speed = summary.seniority_speed + stats.seniority.speed %}
{% endfor %}
{% else %}
{% for category_items in data.total.values() %}
{% for stats in category_items.values() %}
{% set summary.pieces_total = summary.pieces_total + stats.pieces.total %}
{% set summary.pieces_speed = summary.pieces_speed + stats.pieces.speed %}
{% endfor %}
{% endfor %}
{% set summary.seniority_total = data.totalScore %}
{% set summary.seniority_speed = data.score %}
{% endif %}
<div class="container">
<div class="header">
<div class="title-row">
<h1>{{ title }}</h1>
<h1>{{ page_title }}</h1>
<div class="role">
{% if icons.sect.get(force_name) %}
<img class="force-icon" src="{{ icons.sect[force_name] }}" alt="{{ force_name }}">
{% if icons.sect.get(forceName) %}
<img class="force-icon" src="{{ icons.sect[forceName] }}" alt="{{ forceName }}">
{% endif %}
<span>{{ role_name }}</span>
<span>{{ roleName }}</span>
</div>
</div>
<div class="meta">
{{ zone }} · {{ server }} · {{ force_name }} · {{ camp_name }} · 帮会:{{ tong_name }}<br>
资历更新时间:{{ updated_at or "未知" }} · 查询时间:{{ update_time }}
{{ zoneName }} · {{ serverName }} · {{ forceName }} · {{ campName }} · 帮会:{{ tongName or "无" }}<br>
数据来源:JX3API
</div>
</div>
<div class="summary">
<div class="summary-top">
<div class="summary-name">{{ summary.name }}</div>
<div class="summary-percent">{{ summary.percent_text }}</div>
</div>
<div class="score">
{{ summary.completed_points }} / {{ summary.total_points }} 点
· {{ summary.completed_count }} / {{ summary.achievement_count }} 项
</div>
<div class="progress">
<div class="progress-fill" style="width: {{ summary.percent }}%;"></div>
</div>
</div>
{{ progress_card(
'summary',
page_title,
summary.seniority_speed,
summary.seniority_total,
summary.pieces_speed,
summary.pieces_total
) }}
<div class="list">
{% for item in items %}
<div class="item">
<div class="item-top">
<div class="item-name">{{ item.name }}</div>
<div class="item-percent">{{ item.percent_text }}</div>
</div>
<div class="score">
{{ item.completed_points }} / {{ item.total_points }} 点
· {{ item.completed_count }} / {{ item.achievement_count }} 项
</div>
<div class="progress">
<div class="progress-fill" style="width: {{ item.percent }}%;"></div>
</div>
</div>
{% endfor %}
{% if subclass %}
{% for item_name, stats in selected_items.items() %}
{{ progress_card(
'item',
item_name,
stats.seniority.speed,
stats.seniority.total,
stats.pieces.speed,
stats.pieces.total
) }}
{% endfor %}
{% else %}
{% for category_name, category_items in data.total.items() %}
{% set item = namespace(pieces_total=0, pieces_speed=0, seniority_total=0, seniority_speed=0) %}
{% for stats in category_items.values() %}
{% set item.pieces_total = item.pieces_total + stats.pieces.total %}
{% set item.pieces_speed = item.pieces_speed + stats.pieces.speed %}
{% set item.seniority_total = item.seniority_total + stats.seniority.total %}
{% set item.seniority_speed = item.seniority_speed + stats.seniority.speed %}
{% endfor %}
{{ progress_card(
'item',
category_name ~ '总览',
item.seniority_speed,
item.seniority_total,
item.pieces_speed,
item.pieces_total
) }}
{% endfor %}
{% endif %}
</div>
</div>
+3 -12
View File
@@ -1,23 +1,15 @@
{# template-title: 资历排行 #}
{# template-components: data-table #}
<div class="container">
<h1>{{ school }} 资历排行</h1>
<div class="meta">
<div class="data-page__meta">
{{ server }} 更新时间:{{ update_time }}
</div>
<table>
<colgroup>
<col class="rank-width">
<col class="avatar-width">
<col class="force-width">
<col class="role-width">
<col class="server-width">
<col class="score-width">
</colgroup>
<table class="data-table">
<thead>
<tr>
<th>排名</th>
@@ -86,4 +78,3 @@
</tbody>
</table>
</div>
+71 -118
View File
@@ -68,62 +68,6 @@
background: var(--jx3-gold);
}
/* 表格体系 */
.jx3-template :where(.table-wrapper, .table-scroll) {
overflow: hidden;
border: 1px solid var(--jx3-border-soft);
border-radius: var(--jx3-radius-md);
background: rgba(7, 26, 43, 0.76);
box-shadow: var(--jx3-shadow-sm);
}
.jx3-template table {
width: 100%;
overflow: hidden;
border-collapse: collapse;
border-radius: var(--jx3-radius-md);
color: var(--jx3-text-secondary);
background: rgba(7, 26, 43, 0.64);
border-color: var(--jx3-border-soft);
box-shadow: none;
}
.jx3-template tbody tr:nth-child(odd) {
background: transparent;
}
.jx3-template thead {
color: var(--jx3-gold-bright);
background: #102f4a;
border-top: 1px solid var(--jx3-border-strong);
border-bottom: 1px solid var(--jx3-border-strong);
}
.jx3-template :where(th, td) {
padding: 13px 14px;
color: inherit;
border-color: var(--jx3-border-faint);
font-size: 15px;
}
.jx3-template th {
color: var(--jx3-gold-bright);
font-weight: 700;
letter-spacing: 0.03em;
}
.jx3-template tbody tr:nth-child(even) {
background: rgba(31, 74, 107, 0.18);
}
.jx3-template tbody tr:hover {
background: rgba(225, 183, 102, 0.09);
}
.jx3-template tbody tr:last-child td {
border-bottom-color: transparent;
}
/* 状态与重点数据 */
.jx3-template :where(.rank-1, .price-col, .summary-percent, .item-percent, .point-value, .money-gold) {
color: var(--jx3-gold-bright);
@@ -251,32 +195,86 @@
/* @component data-table */
/* 标准数据页:列数和内容由 HTML 决定,视觉规则由所有查询页共用 */
.jx3-template .data-table {
text-align: left;
.jx3-template :where(.table-wrapper, .table-scroll) {
overflow: hidden;
border: 1px solid var(--jx3-border-soft);
border-radius: var(--jx3-radius-md);
background: rgba(7, 26, 43, 0.76);
box-shadow: var(--jx3-shadow-sm);
}
.jx3-template table {
width: 100%;
table-layout: auto;
overflow: hidden;
border-collapse: collapse;
border-radius: var(--jx3-radius-md);
color: var(--jx3-text-secondary);
background: rgba(7, 26, 43, 0.64);
border-color: var(--jx3-border-soft);
text-align: center;
box-shadow: none;
}
.jx3-template thead {
color: var(--jx3-gold-bright);
background: #102f4a;
border-top: 1px solid var(--jx3-border-strong);
border-bottom: 1px solid var(--jx3-border-strong);
}
.jx3-template :where(th, td) {
padding: 13px 14px;
color: inherit;
border-color: var(--jx3-border-faint);
font-size: 15px;
text-align: center;
vertical-align: middle;
}
.jx3-template th {
color: var(--jx3-gold-bright);
font-weight: 700;
letter-spacing: 0.03em;
}
.jx3-template td {
border-bottom: 1px solid var(--jx3-border-faint);
}
.jx3-template tbody tr:nth-child(odd) {
background: transparent;
}
.jx3-template tbody tr:nth-child(even) {
background: rgba(31, 74, 107, 0.18);
}
.jx3-template tbody tr:hover {
background: rgba(225, 183, 102, 0.09);
}
.jx3-template tbody tr:last-child td {
border-bottom-color: transparent;
}
.jx3-template .data-table--spaced {
margin-top: 10px;
}
.jx3-template .data-table--center {
text-align: center;
}
.jx3-template .data-table :where(th, td) {
vertical-align: middle;
}
.jx3-template .data-table--nowrap :where(th, td),
.jx3-template .data-cell--nowrap {
white-space: nowrap;
}
.jx3-template .data-cell--center {
text-align: center;
.jx3-template .data-cell--wrap {
word-break: break-word;
}
.jx3-template .data-cell--wrap {
.jx3-template .data-cell--copy {
color: var(--jx3-text-secondary);
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
@@ -294,69 +292,27 @@
font-variant-numeric: tabular-nums;
}
.jx3-template .data-cell--accent,
.jx3-template .data-cell--score {
color: var(--jx3-gold-bright);
font-weight: 800;
}
.jx3-template .data-cell--rank {
width: 56px;
color: var(--jx3-gold-bright);
font-weight: 800;
text-align: center;
white-space: nowrap;
}
.jx3-template .data-cell--score {
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.jx3-template .data-cell--time {
min-width: 150px;
color: var(--jx3-text-muted);
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.jx3-template .data-cell--copy {
max-width: 360px;
color: var(--jx3-text-secondary);
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.jx3-template .data-cell--copy.data-cell--name {
color: var(--jx3-text);
font-weight: 700;
}
.jx3-template .data-cell--time.data-cell--accent {
.jx3-template .data-cell--accent,
.jx3-template .data-cell--score {
color: var(--jx3-gold-bright);
}
.jx3-template .data-cell--compact {
width: 90px;
}
.jx3-template .data-cell--medium {
min-width: 120px;
}
.jx3-template .data-cell--wide {
min-width: 150px;
}
.jx3-template .data-cell--xwide {
min-width: 220px;
}
.jx3-template .data-cell--max-wide {
max-width: 260px;
font-weight: 800;
}
.jx3-template .data-avatar {
@@ -368,10 +324,6 @@
object-fit: cover;
}
.jx3-template .data-cell--rank.rank-1 {
color: var(--jx3-gold-bright);
}
.jx3-template .data-cell--rank.rank-2 {
color: #c8d2dc;
}
@@ -383,6 +335,7 @@
.jx3-template .icon-label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
color: var(--jx3-text);
+13 -48
View File
@@ -61,14 +61,16 @@
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.1);
}
.main-icon img {
.main-icon img,
.material-icon img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.main-info {
.main-info,
.material-info {
min-width: 0;
}
@@ -100,31 +102,6 @@
white-space: nowrap;
}
.quality-1 {
color: #555;
background: #efefef;
}
.quality-2 {
color: #237a3b;
background: #e5f6e9;
}
.quality-3 {
color: #1e6bd6;
background: #e7f0fd;
}
.quality-4 {
color: #7c3fc2;
background: #f0e7fb;
}
.quality-5 {
color: #c76b00;
background: #fff0d9;
}
.tip-list {
margin: 0 0 22px;
padding: 0;
@@ -211,11 +188,13 @@
font-weight: 800;
}
.summary-title.market {
.summary-title.market,
.price-box.highest .price-box-value {
color: #d93939;
}
.summary-title.cost {
.summary-title.cost,
.price-box.lowest .price-box-value {
color: #2e8b57;
}
@@ -251,18 +230,10 @@
line-height: 1.55;
}
.price-box.lowest .price-box-value {
color: #2e8b57;
}
.price-box.average .price-box-value {
color: #d98b16;
}
.price-box.highest .price-box-value {
color: #d93939;
}
.summary-footer {
display: flex;
justify-content: space-between;
@@ -406,17 +377,6 @@
border-radius: 14px;
}
.material-icon img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.material-info {
min-width: 0;
}
.material-name {
margin: 0 0 10px;
font-size: 26px;
@@ -455,26 +415,31 @@
background: #e7f5ed;
}
.quality-1,
.tag-quality-1 {
color: #555;
background: #efefef;
}
.quality-2,
.tag-quality-2 {
color: #237a3b;
background: #e5f6e9;
}
.quality-3,
.tag-quality-3 {
color: #1e6bd6;
background: #e7f0fd;
}
.quality-4,
.tag-quality-4 {
color: #7c3fc2;
background: #f0e7fb;
}
.quality-5,
.tag-quality-5 {
color: #c76b00;
background: #fff0d9;
+3 -23
View File
@@ -1,10 +1,4 @@
.description {
margin-bottom: 24px;
color: #7a7f8c;
font-size: 15px;
}
.legend {
.legend {
display: flex;
justify-content: center;
align-items: center;
@@ -32,22 +26,6 @@
.legend-color.unfinished {
background: var(--jx3-green);
}table {
table-layout: fixed;
margin-top: 10px;
text-align: left;
}th,
td {
vertical-align: middle;
}
th:first-child,
td:first-child {
width: 250px;
}
.map-info {
text-align: center;
}
.map-name {
@@ -67,6 +45,7 @@
.boss-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
@@ -137,6 +116,7 @@
.boss-status {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 6px;
font-size: 12px;
+2 -5
View File
@@ -52,7 +52,8 @@
grid-column: span 2;
}
.section.span-3 {
.section.span-3,
.full-row {
grid-column: 1 / -1;
}
@@ -132,10 +133,6 @@
margin-top: 4px;
}
.full-row {
grid-column: 1 / -1;
}
.push-section .section-body {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
-47
View File
@@ -1,11 +1,5 @@
.header {
color: var(--jx3-text);
background: linear-gradient(180deg, rgba(17, 52, 81, 0.96), rgba(9, 35, 57, 0.96));
border: 1px solid var(--jx3-border-soft);
border-radius: 8px;
padding: 22px 26px;
margin-bottom: 16px;
box-shadow: 0 6px 18px rgba(35, 38, 47, 0.06);
}
.meta {
@@ -16,47 +10,6 @@
color: var(--jx3-text-muted);
}
.table-wrapper {
background: rgba(7, 26, 43, 0.76);
border: 1px solid var(--jx3-border-soft);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(35, 38, 47, 0.06);
}table {
table-layout: fixed;
}
th {
padding: 14px 16px;
font-size: 16px;
font-weight: 700;
text-align: left;
white-space: nowrap;
}
th:nth-child(2),
th:nth-child(3),
th:nth-child(4),
td:nth-child(2),
td:nth-child(3),
td:nth-child(4) {
text-align: center;
}
td {
padding: 14px 16px;
border-bottom: 1px solid var(--jx3-border-faint);
font-size: 15px;
vertical-align: middle;
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.item-icon {
width: 34px;
height: 34px;
+1 -33
View File
@@ -1,40 +1,11 @@
.meta {
margin-bottom: 24px;
color: #7a7f99;
font-size: 16px;
text-align: center;
}table {
margin-top: 10px;
table-layout: fixed;
text-align: left;
}th,
td {
vertical-align: middle;
}
th {
font-weight: 700;
text-align: center;
}
/* 角色列 */
.role-col {
width: 16%;
.role-col {
color: var(--jx3-text);
font-weight: 700;
text-align: center;
word-break: break-word;
}
/* 频道列 */
.channel-col {
width: 10%;
text-align: center;
}
/* 消息列 */
.message-col {
width: 56%;
color: var(--jx3-text-secondary);
font-weight: 600;
line-height: 1.65;
@@ -44,9 +15,7 @@
/* 时间列 */
.time-col {
width: 18%;
color: #757b8f;
text-align: center;
white-space: nowrap;
}
@@ -95,5 +64,4 @@
padding: 50px 20px;
color: #8a8fa3;
font-size: 17px;
text-align: center;
}
+4 -30
View File
@@ -30,7 +30,7 @@
font-weight: 800;
}
.table-wrapper {
.market-table-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
@@ -50,23 +50,6 @@
color: var(--jx3-gold-bright);
}
.table-scroll {
width: 100%;
overflow-x: auto;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}table {
min-width: 420px;
text-align: left;
}th,
td {
white-space: nowrap;
}
th {
font-weight: 700;
}
.server-col {
font-weight: 600;
}
@@ -86,10 +69,7 @@
/* ===== 万宝楼公示(蓝色) ===== */
.table-card.notice .table-title {
color: var(--jx3-blue);
}
.table-card.notice .table-title,
.table-card.notice .price-col {
color: var(--jx3-blue);
}
@@ -100,10 +80,7 @@
/* ===== 万宝楼在售(绿色) ===== */
.table-card.sale .table-title {
color: var(--jx3-green);
}
.table-card.sale .table-title,
.table-card.sale .price-col {
color: var(--jx3-green);
}
@@ -114,10 +91,7 @@
/* ===== 查询区服(紫色) ===== */
.table-card.query .table-title {
color: var(--jx3-purple);
}
.table-card.query .table-title,
.table-card.query .price-col {
color: var(--jx3-purple);
}
+4 -68
View File
@@ -1,30 +1,21 @@
.panel {
width: 1100px;
margin: auto;
background: #ffffff;
border-radius: 12px;
padding: 22px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
}
/* ===== 头部 ===== */
.header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding-bottom: 14px;
}
.role-name {
font-size: 40px;
font-weight: bold;
color: #1f1f1f;
}
.sub-info {
margin-top: 6px;
font-size: 22px;
color: #666;
}
/* ===== 头像 + 核心数据 ===== */
@@ -61,8 +52,6 @@
/* 单个数据块 */
.stat-box {
background: #f5f6fa;
border-radius: 12px;
padding: 20px;
text-align: center;
}
@@ -76,50 +65,22 @@
font-size: 36px;
margin-top: 8px;
font-weight: bold;
color: #222;
}
.winrate {
color: #2fbf71;
}
.mmr {
color: #f5a623;
}
/* ===== 表格 ===== */table {
margin-top: 20px;
font-size: 18px;
table-layout: fixed;
}th, td {
text-align: center;
}
th {
color: #666;
font-weight: normal;
}
tbody tr:nth-child(odd) {
background: #ffffff;
}
.win {
color: #2fbf71;
font-weight: bold;
}
.lose {
color: #e65050;
font-weight: bold;
}
.win,
.mmr-up {
color: #2fbf71;
font-weight: bold;
}
.lose,
.mmr-down {
color: #e65050;
font-weight: bold;
}
.mvp {
@@ -128,31 +89,6 @@ tbody tr:nth-child(odd) {
}
.time-col {
width: 170px;
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.duration-col {
width: 92px;
}
.kungfu-col {
width: 150px;
}
.result-col {
width: 64px;
}
.score-col {
width: 104px;
}
.grade-col {
width: 96px;
}
.mvp-col {
width: 58px;
}
-7
View File
@@ -1,8 +1,4 @@
.header {
background: #ffffff;
border-radius: 14px;
padding: 22px 26px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
@@ -37,10 +33,7 @@
}
.summary {
background: #ffffff;
border-radius: 14px;
padding: 22px 26px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
+6 -68
View File
@@ -1,69 +1,20 @@
.meta {
margin-bottom: 26px;
color: #7a7f99;
font-size: 18px;
text-align: center;
}table {
table-layout: fixed;
}th,
td {
height: 66px;
vertical-align: middle;
}
th {
height: 52px;
font-size: 18px;
font-weight: 700;
text-align: center;
white-space: nowrap;
}
/*
* 列宽分配
*/
.rank-width {
width: 8%;
}
.avatar-width {
width: 9%;
}
.force-width {
width: 19%;
}
.role-width {
width: 25%;
}
.server-width {
width: 23%;
}
.score-width {
width: 16%;
}
/*
/*
* 排名列
*/
.rank-col {
color: #d93939;
font-size: 18px;
font-weight: 800;
text-align: center;
white-space: nowrap;
}
/*
* 头像列
*/
.avatar-col {
.avatar-col,
.force-col {
padding-left: 4px;
padding-right: 4px;
text-align: center;
}
.avatar {
@@ -79,12 +30,6 @@
/*
* 职业列
*/
.force-col {
padding-left: 4px;
padding-right: 4px;
text-align: center;
}
.force {
display: flex;
align-items: center;
@@ -107,7 +52,9 @@
background: #eef2f8;
}
.force-name {
.force-name,
.role-name-col,
.server-col {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -123,10 +70,6 @@
color: var(--jx3-text);
font-size: 17px;
font-weight: 600;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
@@ -137,10 +80,6 @@
padding-right: 8px;
color: var(--jx3-text-secondary);
font-size: 17px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
@@ -152,7 +91,6 @@
color: #d93939;
font-size: 18px;
font-weight: 800;
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
+1 -4
View File
@@ -75,10 +75,7 @@
--jx3-page-width: 1080px;
}
.jx3-template--richangyuche {
--jx3-page-width: 1100px;
}
.jx3-template--richangyuche,
.jx3-template--zhanji {
--jx3-page-width: 1100px;
}