This commit is contained in:
2026-06-24 13:01:52 +08:00
parent f0e535c8ca
commit d8ceb93ea7
9 changed files with 996 additions and 426 deletions
+11 -1
View File
@@ -1,5 +1,15 @@
## 更新记录
### version: 2.6
新增 **资历 角色名称 [服务器]** 指令,支持两轮对话选择资历总览或分类总览,并以图片形式展示角色资历点数完成进度。
新增资历菜单与点数基础数据缓存,减少长期稳定接口的重复请求。
调整功能帮助页分类,指令分组改为日常、官方、角色、职业、休闲、阵营、副本、活动、交易、杂项、排行、推送和避雷。
调整指令触发词:**诛恶事件** 改为 **诛恶****排行** 改为 **名剑排行**
### version: 2.5
增加部分新指令,调整功能页面
@@ -58,4 +68,4 @@
### version: 1.0.0
大部分重要功能已经实现
大部分重要功能已经实现
+38 -13
View File
@@ -2,24 +2,27 @@
基于 [AstrBot](https://docs.astrbot.app/) 框架开发的剑网三游戏数据查询插件。插件主要通过 JX3API、JX3BOX、茶馆等外部接口获取游戏数据,整理为文本消息、富媒体消息,或通过 `HTML + Jinja2` 模板渲染成图片后发送。
当前插件以“查询型功能”为主,覆盖日常、奇遇、名剑、阵营、交易、职业、排行、副本、活动、名片、百战、推送和本地避雷等场景。部分功能需要配置 JX3API Token,少量职业/角色类接口还需要推栏 ticket。
当前插件以“查询型功能”为主,覆盖日常、官方、角色、职业、休闲、阵营、副本、活动、交易、杂项、排行、推送和本地避雷等场景。部分功能需要配置 JX3API Token,少量职业/角色类接口还需要推栏 ticket。
## 功能概览
插件内置 `功能` 指令,会以图片形式展示完整指令列表、参数说明和是否需要令牌。当前帮助页按 16 个功能分组维护,约 87 条指令。
插件内置 `功能` 指令,会以图片形式展示完整指令列表、参数说明和是否需要令牌。当前帮助页按 13 个功能分组维护,约 88 条指令。
主要功能包括:
- 日常与官方:日常、月历、名望、公告、维护、区服、开服、状态、技改
- 职业与角色:角色、阵眼、奇穴、技能、小药、宏、配装、资历排行、试炼排行
- 奇遇:角色奇遇、未做奇遇、奇遇统计、近期奇遇、奇遇汇总、奇遇攻略
- 名剑:战绩、名剑排行、名剑统计
- 阵营:沙盘、阵营拍卖、阵营事件、关隘首领、帮战记录、统战 YY
- 交易:金价、外观物价、交易行、贴吧物价
- 活动:扶摇、诛恶事件、本日赤兔、本周赤兔、刷马、的卢
- 其他:拜师、收徒、名片、百战、精耐、招募、掉落、解密、八卦、骗子查询
- 本地功能:避雷添加、查看、查询、修改、删除
- 日常功能:日常、月历、名望、诛恶
- 官方功能:公告、维护、技改、区服、开服、状态
- 角色功能:角色、资历、名片、所有名片、奇遇、未做奇遇、战绩、精耐
- 职业功能:小药、宏、配装、阵眼、奇穴、技能
- 休闲功能:烟花、拜师、收徒、随机名片、攻略、奇遇统计、近期奇遇、奇遇汇总
- 阵营功能:沙盘、阵营拍卖、阵营事件、关隘首领、帮战记录、统战 YY
- 副本功能:百战、招募、掉落、解密
- 活动功能:扶摇、本日赤兔、本周赤兔、刷马、的卢
- 交易功能:金价、物价、交易行、贴吧物价
- 杂项功能:科举、花价、装饰、器物、八卦、骗子、骚话。
- 排行功能:名剑统计、名剑排行、资历排行、试炼排行和各类风云录榜单。
- 推送功能:开服推送、新闻推送、刷马推送、赤兔推送。
- 避雷功能:避雷查看、添加、删除、修改、查询。
## 安装与依赖
@@ -43,7 +46,7 @@ pip install -r requirements.txt
- `apscheduler`:后台推送任务调度。
- `matplotlib`:部分图像/数据展示依赖。
插件元信息在 `metadata.yaml` 中维护,要求 AstrBot 版本 `>=4.11.0`
插件元信息在 `metadata.yaml` 中维护,当前版本为 `v2.6`要求 AstrBot 版本 `>=4.11.0`
## 插件配置
@@ -166,9 +169,12 @@ pip install -r requirements.txt
```text
日常
诛恶
资历 飞翔大野猪
奇遇 飞翔大野猪
未做奇遇 飞翔大野猪 梦江南
战绩 飞翔大野猪 梦江南 33
名剑排行 50 33
贴吧物价 狐金 5 梦江南
统战 梦江南
```
@@ -234,6 +240,7 @@ astrbot_plugin_jx3/
- `image_msg()`:直接发送图片 URL 或图片数据。
- `plain_chain()`:发送文本 + 图片等富媒体消息链。
- `handler_plain_image_msg()`:先发文本,再发图片,适用于部分组合型功能。
- `handler_zili_msg()`:资历查询专用两轮会话,先发送 `0-18` 分类菜单,再按用户选择渲染资历进度图片。
所有可选服务器参数都会通过 `serverdefault()` 补齐默认服务器。
@@ -332,10 +339,28 @@ html_renderer.render_custom_template()
- 避雷记录。
- 推送任务状态缓存。
- 资历菜单与资历点数基础数据缓存,默认缓存 30 天,接口失败时可使用旧缓存兜底。
`core/sqlite.py` 封装了异步增删改查;`core/bilei_data.py` 基于该封装实现避雷数据管理。
### 8. 后台推送
### 8. 资历查询
`资历 角色名称 [服务器]` 是 2.6 新增的两轮对话功能。
第一轮会发送固定分类菜单,用户输入 `0-18` 后进入第二轮:
- `0`:展示角色资历总览,并列出 18 个大类的完成进度。
- `1-18`:展示对应大类总览,并列出该大类下所有子类的完成进度。
实现流程:
1. 通过角色信息接口 `jx3_jueshexinxi` 获取角色 `globalId`
2.`globalId` 请求 JX3BOX 角色资历接口,获取已完成资历 ID 列表。
3. 从资历菜单接口和资历点数接口读取基础数据,并缓存到本地 SQLite。
4. 展开菜单中单个 ID 和数组 ID,按资历点数计算 `已完成点数 / 总点数` 与百分比。
5. 使用 `templates/zili.html` 渲染进度条图片。
### 9. 后台推送
`core/async_task.py` 使用 APScheduler 实现轮询推送。
+266 -3
View File
@@ -1,5 +1,5 @@
import json
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Union
from collections import defaultdict
@@ -34,8 +34,32 @@ TONG_RANK_NAMES = {
"本周浩气五十强",
}
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}
class JX3Service:
def __init__(self, api_data_path: str, config: AstrBotConfig, sqlite: AsyncSQLiteDB):
def __init__(self, api_data_path: str, config: AstrBotConfig, sqlite: AsyncSQLiteDB, cache_sqlite: Optional[AsyncSQLiteDB] = None):
# 加载API配置文件
try:
with open(api_data_path, 'r', encoding='utf-8') as f:
@@ -49,6 +73,7 @@ class JX3Service:
self._config = config
# 引用sqlite
self._sql_db = sqlite
self._cache_db = cache_sqlite or sqlite
# 获取配置中的 Token
self.token = self._config.get("jx3api_token", "")
@@ -2386,6 +2411,244 @@ class JX3Service:
return return_data
async def _load_achievement_cache(self, key: str) -> tuple[Optional[Dict[str, Any]], bool]:
"""读取资历基础数据缓存,返回数据和是否已过期"""
try:
row = await self._cache_db.select_one("achievement_cache", "key=?", (key,))
except Exception as e:
logger.error(f"读取资历缓存失败: {e}")
return None, True
if not row:
return None, True
try:
payload = json.loads(row.get("content", "{}"))
updated_at = datetime.strptime(row.get("updated_at", ""), "%Y-%m-%d %H:%M:%S")
expired = datetime.now() - updated_at > timedelta(days=30)
return payload, expired
except Exception as e:
logger.error(f"解析资历缓存失败: {e}")
return None, True
async def _save_achievement_cache(self, key: str, payload: Dict[str, Any]):
"""写入资历基础数据缓存"""
try:
await self._cache_db.execute(
"""
INSERT INTO achievement_cache (key, content, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
content=excluded.content,
updated_at=excluded.updated_at
""",
(
key,
json.dumps(payload, ensure_ascii=False),
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
),
)
except Exception as e:
logger.error(f"写入资历缓存失败: {e}")
async def _get_achievement_base_data(self, cache_key: str, config_key: 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(config_key, "GET")
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
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}
role_data: Optional[Dict[str, Any]] = await self._base_request(
"jx3_jueshexinxi", "GET", params=role_params
)
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._api.get(
"https://next2.jx3box.com/api/next2/user-achievements",
params={"jx3id": global_id},
out_key="data",
)
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",
"jx3box_achievement_menus",
)
point_payload = await self._get_achievement_base_data(
"achievement_points",
"jx3box_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 jueshe(self,name: str, server: str) -> Dict[str, Any]:
"""角色"""
return_data = self._init_return_data()
@@ -2408,7 +2671,7 @@ class JX3Service:
f"服务器:{data.get('zoneName', '')}·{data.get('serverName', '')}\n"
f"名称:{data.get('roleName', '')}\n"
f"角色ID{data.get('roleId', '')}\n"
f"全区ID{data.get('globalRoleId', '')}\n"
f"全区ID{data.get('globalId', '')}\n"
f"职业:{data.get('forceName', '')}·{data.get('bodyName', '')}\n"
f"帮会:{data.get('tongName', '')}\n"
f"阵营:{data.get('campName', '')}\n"
+86
View File
@@ -13,6 +13,29 @@ 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, server: str, jx3api: JX3Service, bilei: BiLeidata, jx3at: AsyncTask, icons: dict[str, dict[str, str]]):
self.server = server
self.jx3api = jx3api
@@ -187,6 +210,64 @@ class MessageBuilder:
await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def handler_zili_msg(self, event: AstrMessageEvent, name: str, server: str):
"""资历查询专用两轮会话,第一轮文本菜单,第二轮图片"""
try:
await event.send(event.plain_result(self.ZILI_MENU_TEXT))
user_id = event.get_sender_id()
@session_waiter(timeout=30)
async def zili_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
choice = int(msg)
if choice < 0 or choice > 18:
await new_event.send(MessageChain().message("无效序号,结束会话"))
controller.stop()
return
try:
data = await self.jx3api.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))
except Exception as e:
logger.error(f"资历查询执行错误: {e}")
await new_event.send(MessageChain().message("猪脑过载,请稍后再试"))
controller.stop()
try:
await zili_select_waiter(event)
except TimeoutError:
await event.send(event.plain_result("选择超时,已结束会话"))
except Exception:
logger.error("资历选择发生异常", exc_info=True)
except Exception as e:
logger.error(f"资历会话执行错误: {e}")
await event.send(event.plain_result("猪脑过载,请稍后再试"))
async def jx3_helps(self, event: AstrMessageEvent):
"""剑三 功能"""
return await self.T2I_image_msg(event, self.jx3api.helps)
@@ -292,6 +373,11 @@ class MessageBuilder:
return await self.plain_msg(event, self.jx3api.shaohua)
async def jx3_zili(self, event: AstrMessageEvent, name: str, server: str = ""):
"""剑三 资历 角色名称 服务器"""
return await self.handler_zili_msg(event, name, self.serverdefault(server))
async def jx3_jiemi(self, event: AstrMessageEvent):
"""剑三 解密"""
return await self.plain_msg(event, self.jx3api.jiemi)
+17
View File
@@ -591,6 +591,23 @@
"mount": "",
"star": "1"
}
},
"jx3box_achievement_menus":{
"url":"https://node.jx3box.com/api/node/achievement/menus",
"method":"GET",
"description":"魔盒资历菜单列表",
"params":{
"general": "1",
"client": "std"
}
},
"jx3box_achievement_points":{
"url":"https://node.jx3box.com/api/node/achievement/points",
"method":"GET",
"description":"魔盒资历点数列表",
"params":{
"client": "std"
}
}
+17 -4
View File
@@ -17,7 +17,7 @@ from .core.fun_basic import load_as_base64
@register("astrbot_plugin_jx3",
"fxdyz",
"通过调用剑网三API接口获取游戏数据,处理发送。",
"2.5",
"2.6",
"https://github.com/qsc20001102/astrbot_plugin_jx3api"
)
class Jx3ApiPlugin(Star):
@@ -57,6 +57,7 @@ class Jx3ApiPlugin(Star):
# 数据库初始化
await self.init_bilei_data()
await self.init_tuishong_data()
await self.init_achievement_cache_data()
# 连接插件数据
await self.plugin_sql_db.connect()
@@ -142,7 +143,7 @@ class Jx3ApiPlugin(Star):
self.plugin_sql_db = AsyncSQLiteDB(str(self.plugin_data_path))
# 剑网三功能实例化
self.bilei = BiLeidata(self.local_sql_db)
self.jx3api = JX3Service(str(self.api_data_path), self.conf, self.plugin_sql_db)
self.jx3api = JX3Service(str(self.api_data_path), self.conf, self.plugin_sql_db, self.local_sql_db)
self.jx3at = AsyncTask(cast(Context, self.context), self.conf, self.jx3api, self.local_sql_db)
self.jx3cmd = MessageBuilder(self.server, self.jx3api, self.bilei, self.jx3at, self.icons)
self.jx3cmd = MessageBuilder(self.server, self.jx3api, self.bilei, self.jx3at, self.icons)
@@ -182,6 +183,17 @@ class Jx3ApiPlugin(Star):
""")
async def init_achievement_cache_data(self):
"""初始化资历基础数据缓存表"""
await self.local_sql_db.execute("""
CREATE TABLE IF NOT EXISTS achievement_cache(
key TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
def ini_command_map(self):
"""初始化指令集"""
self.command_map = {
@@ -206,6 +218,7 @@ class Jx3ApiPlugin(Star):
"资历排行": self.jx3cmd.jx3_zilipaixing,
"试炼排行": self.jx3cmd.jx3_shilianpaixing,
"骚话": self.jx3cmd.jx3_shaohua,
"资历": self.jx3cmd.jx3_zili,
"解密": self.jx3cmd.jx3_jiemi,
"沙盘": self.jx3cmd.jx3_shapan,
"攻略": self.jx3cmd.jx3_qiyugonglue,
@@ -213,7 +226,7 @@ class Jx3ApiPlugin(Star):
"配装": self.jx3cmd.jx3_peizhuang,
"百战": self.jx3cmd.jx3_baizhan,
"扶摇": self.jx3cmd.jx3_fuyaojjiutian,
"诛恶事件": self.jx3cmd.jx3_zhueevent,
"诛恶": self.jx3cmd.jx3_zhueevent,
"本日赤兔": self.jx3cmd.jx3_benrichitu,
"本周赤兔": self.jx3cmd.jx3_benzhouchitu,
"拍卖": self.jx3cmd.jx3_zhengyingpaimai,
@@ -230,7 +243,7 @@ class Jx3ApiPlugin(Star):
"近期奇遇": self.jx3cmd.jx3_jinqiqiyu,
"奇遇汇总": self.jx3cmd.jx3_qiyuhuizong,
"战绩": self.jx3cmd.jx3_zhanji,
"排行": self.jx3cmd.jx3_mingjianpaihang,
"名剑排行": self.jx3cmd.jx3_mingjianpaihang,
"名剑统计": self.jx3cmd.jx3_mingjiantongji,
"招募": self.jx3cmd.jx3_tuanduizhaomu,
"拜师": self.jx3cmd.jx3_baishi,
+1 -1
View File
@@ -1,7 +1,7 @@
name: astrbot_plugin_jx3
display_name: 剑网三游戏数据查询工具
desc: 通过接口调用剑网三API接口获取游戏数据,处理发送。
version: v2.5
version: v2.6
author: 飞翔大野猪
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
astrbot_version: ">=4.11.0"
+354 -404
View File
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>资历查询</title>
<style>
body {
font-family: "Microsoft YaHei", Arial, sans-serif;
background: #f1f2f6;
margin: 0;
padding: 22px;
color: #262b36;
}
.container {
width: 980px;
margin: 0 auto;
}
.header {
background: #ffffff;
border-radius: 14px;
padding: 22px 26px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
h1 {
margin: 0;
font-size: 32px;
line-height: 1.2;
color: #222;
font-weight: 800;
}
.role {
display: flex;
align-items: center;
gap: 10px;
color: #565d70;
font-size: 16px;
white-space: nowrap;
}
.force-icon {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: contain;
}
.meta {
margin-top: 12px;
color: #7a7f99;
font-size: 15px;
line-height: 1.7;
}
.summary {
background: #ffffff;
border-radius: 14px;
padding: 22px 26px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
.summary-top,
.item-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
}
.summary-name {
font-size: 24px;
font-weight: 800;
color: #222;
}
.summary-percent {
font-size: 34px;
line-height: 1;
font-weight: 900;
color: #d93939;
font-variant-numeric: tabular-nums;
}
.score {
margin-top: 8px;
color: #656d80;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.progress {
height: 14px;
margin-top: 14px;
border-radius: 999px;
overflow: hidden;
background: #e7e9ef;
}
.progress-fill {
height: 100%;
min-width: 3px;
border-radius: 999px;
background: linear-gradient(90deg, #d93939, #f59e42);
}
.list {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.item {
background: #ffffff;
border-radius: 12px;
padding: 15px 16px;
box-shadow: 0 3px 12px rgba(0,0,0,0.06);
}
.item-name {
min-width: 0;
font-size: 17px;
font-weight: 800;
color: #303543;
word-break: break-word;
}
.item-percent {
color: #d93939;
font-size: 18px;
font-weight: 900;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.item .score {
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="title-row">
<h1>{{ title }}</h1>
<div class="role">
{% if icons.sect.get(force_name) %}
<img class="force-icon" src="{{ icons.sect[force_name] }}" alt="{{ force_name }}">
{% endif %}
<span>{{ role_name }}</span>
</div>
</div>
<div class="meta">
{{ zone }} · {{ server }} · {{ force_name }} · {{ camp_name }} · 帮会:{{ tong_name }}<br>
资历更新时间:{{ updated_at or "未知" }} · 查询时间:{{ update_time }}
</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>
<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 %}
</div>
</div>
</body>
</html>