新增通用HTML模板渲染器并将机器人菜单改为模板驱动输出
This commit is contained in:
@@ -12,6 +12,9 @@ image_fallback_to_text = false
|
|||||||
# md2image 渲染参数:可按服务器性能调整
|
# md2image 渲染参数:可按服务器性能调整
|
||||||
image_render_timeout_seconds = 45
|
image_render_timeout_seconds = 45
|
||||||
image_render_retries = 1
|
image_render_retries = 1
|
||||||
|
# 菜单图片 HTML 模板路径(相对仓库根目录)
|
||||||
|
# 后续 UI 改版只需要改模板文件,无需改 Python 代码
|
||||||
|
image_template_path = "plugins/robot_menu/templates/menu_cards.html"
|
||||||
command-format = """
|
command-format = """
|
||||||
📝功能菜单指令:
|
📝功能菜单指令:
|
||||||
菜单 - 显示功能菜单
|
菜单 - 显示功能菜单
|
||||||
|
|||||||
@@ -87,12 +87,21 @@ class RobotMenuPlugin(MessagePluginInterface):
|
|||||||
self.image_render_retries = int(
|
self.image_render_retries = int(
|
||||||
self._config.get("RobotMenu", {}).get("image_render_retries", 1)
|
self._config.get("RobotMenu", {}).get("image_render_retries", 1)
|
||||||
)
|
)
|
||||||
|
# 菜单图片模板文件路径(相对仓库根目录):
|
||||||
|
# 调整样式和布局时只改模板,不改 Python 逻辑。
|
||||||
|
self.image_template_path = str(
|
||||||
|
self._config.get("RobotMenu", {}).get(
|
||||||
|
"image_template_path",
|
||||||
|
"plugins/robot_menu/templates/menu_cards.html",
|
||||||
|
)
|
||||||
|
).strip() or "plugins/robot_menu/templates/menu_cards.html"
|
||||||
# 初始化“菜单渲染工具”,后续菜单图片与文本发送统一由该工具负责。
|
# 初始化“菜单渲染工具”,后续菜单图片与文本发送统一由该工具负责。
|
||||||
self.menu_renderer = RobotMenuRenderTool(
|
self.menu_renderer = RobotMenuRenderTool(
|
||||||
output_mode=output_mode,
|
output_mode=output_mode,
|
||||||
image_fallback_to_text=self.image_fallback_to_text,
|
image_fallback_to_text=self.image_fallback_to_text,
|
||||||
image_render_timeout_seconds=self.image_render_timeout_seconds,
|
image_render_timeout_seconds=self.image_render_timeout_seconds,
|
||||||
image_render_retries=self.image_render_retries,
|
image_render_retries=self.image_render_retries,
|
||||||
|
image_template_path=self.image_template_path,
|
||||||
log=self.LOG,
|
log=self.LOG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import html
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -11,6 +10,7 @@ from loguru import logger as default_logger
|
|||||||
from utils.markdown_to_image import convert_md_str_to_image, html_to_image
|
from utils.markdown_to_image import convert_md_str_to_image, html_to_image
|
||||||
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
||||||
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus
|
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus
|
||||||
|
from utils.html_template_renderer import HtmlTemplateRenderer
|
||||||
from wechat_ipad import WechatAPIClient
|
from wechat_ipad import WechatAPIClient
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ class RobotMenuRenderTool:
|
|||||||
image_fallback_to_text: bool,
|
image_fallback_to_text: bool,
|
||||||
image_render_timeout_seconds: int,
|
image_render_timeout_seconds: int,
|
||||||
image_render_retries: int,
|
image_render_retries: int,
|
||||||
|
image_template_path: str,
|
||||||
log=default_logger,
|
log=default_logger,
|
||||||
):
|
):
|
||||||
# 输出模式:支持 text / image,非法值自动归一化为 text。
|
# 输出模式:支持 text / image,非法值自动归一化为 text。
|
||||||
@@ -40,6 +41,9 @@ class RobotMenuRenderTool:
|
|||||||
self.image_render_retries = int(image_render_retries)
|
self.image_render_retries = int(image_render_retries)
|
||||||
# 注入日志对象,便于主插件统一控制日志风格与输出目标。
|
# 注入日志对象,便于主插件统一控制日志风格与输出目标。
|
||||||
self.log = log or default_logger
|
self.log = log or default_logger
|
||||||
|
# 菜单图片模板路径(相对仓库根目录),支持仅改模板文件完成 UI 更新。
|
||||||
|
self.image_template_path = str(image_template_path or "").strip() or "plugins/robot_menu/templates/menu_cards.html"
|
||||||
|
self.template_renderer = HtmlTemplateRenderer()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_output_mode(raw_mode: Any) -> str:
|
def normalize_output_mode(raw_mode: Any) -> str:
|
||||||
@@ -132,268 +136,58 @@ class RobotMenuRenderTool:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _get_feature_command_examples(self, feature: Feature) -> str:
|
def _build_feature_command_payload(self, feature: Feature) -> dict:
|
||||||
"""返回单个功能在卡片中的紧凑使用说明(不包含管理员内容)。"""
|
"""构建功能卡片中的命令展示数据。"""
|
||||||
_, cmd_hint = self._split_feature_description(feature.description)
|
_, cmd_hint = self._split_feature_description(feature.description)
|
||||||
tokens = self._extract_command_tokens(cmd_hint)
|
tokens = self._extract_command_tokens(cmd_hint)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return "自动/定时触发,无需发送命令"
|
return {
|
||||||
|
"is_auto": True,
|
||||||
rows = [f"<span class='line-main'>指令:<code>{html.escape(tokens[0])}</code></span>"]
|
"primary": "",
|
||||||
if len(tokens) > 1:
|
"aliases": [],
|
||||||
alias_text = " / ".join([f"<code>{html.escape(t)}</code>" for t in tokens[1:4]])
|
"tip": "自动/定时触发,无需发送命令",
|
||||||
rows.append(f"<span class='line-sub'>别名:{alias_text}</span>")
|
}
|
||||||
return "<br>".join(rows)
|
return {
|
||||||
|
"is_auto": False,
|
||||||
|
"primary": tokens[0],
|
||||||
|
"aliases": tokens[1:4],
|
||||||
|
"tip": "",
|
||||||
|
}
|
||||||
|
|
||||||
def build_feature_status_html(self, group_id: str) -> str:
|
def build_feature_status_html(self, group_id: str) -> str:
|
||||||
"""构建 PlayStation 风格菜单 HTML(黑-白-蓝三段式)。"""
|
"""基于外部 HTML 模板构建菜单页面。"""
|
||||||
# 设计实现说明(基于 DESIGN-playstation.md):
|
|
||||||
# 1. 三段式表面:黑色英雄区 -> 白色内容区 -> 蓝色底部;
|
|
||||||
# 2. 主色锚点固定为 PlayStation Blue #0070cc,交互强调色为 #1eaedb;
|
|
||||||
# 3. 组件采用圆角胶囊/卡片体系,保持信息密度与可读性平衡。
|
|
||||||
user_features = self._iter_user_command_features()
|
user_features = self._iter_user_command_features()
|
||||||
feature_cards = []
|
feature_cards = []
|
||||||
enabled_count = 0
|
enabled_count = 0
|
||||||
for feature in user_features:
|
for feature in user_features:
|
||||||
title, _ = self._split_feature_description(feature.description)
|
title, _ = self._split_feature_description(feature.description)
|
||||||
command_examples = self._get_feature_command_examples(feature)
|
|
||||||
status = GroupBotManager.get_group_permission(group_id, feature)
|
status = GroupBotManager.get_group_permission(group_id, feature)
|
||||||
is_enabled = status == PermissionStatus.ENABLED
|
is_enabled = status == PermissionStatus.ENABLED
|
||||||
if is_enabled:
|
if is_enabled:
|
||||||
enabled_count += 1
|
enabled_count += 1
|
||||||
status_text = "开启" if is_enabled else "关闭"
|
command_payload = self._build_feature_command_payload(feature)
|
||||||
status_class = "status-on" if is_enabled else "status-off"
|
|
||||||
feature_cards.append(
|
feature_cards.append(
|
||||||
f"""
|
{
|
||||||
<section class="feature-card">
|
"index": int(feature.value),
|
||||||
<div class="feature-top">
|
"title": title,
|
||||||
<div class="feature-index">{feature.value:02d}</div>
|
"feature_key": str(feature.name),
|
||||||
<div class="feature-meta">
|
"status_text": "开启" if is_enabled else "关闭",
|
||||||
<h3>{html.escape(title)}</h3>
|
"status_class": "status-on" if is_enabled else "status-off",
|
||||||
<p class="feature-key">功能键:<code>{html.escape(feature.name)}</code></p>
|
"command": command_payload,
|
||||||
</div>
|
}
|
||||||
<span class="status-badge {status_class}">{status_text}</span>
|
|
||||||
</div>
|
|
||||||
<div class="feature-body">
|
|
||||||
<p>{command_examples}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
return f"""
|
return self.template_renderer.render(
|
||||||
<html>
|
self.image_template_path,
|
||||||
<head>
|
{
|
||||||
<meta charset="UTF-8">
|
"group_id": str(group_id or ""),
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
"now_text": now_text,
|
||||||
<style>
|
"feature_total": len(user_features),
|
||||||
:root {{
|
"enabled_count": enabled_count,
|
||||||
--ps-blue: #0070cc;
|
"feature_cards": feature_cards,
|
||||||
--ps-cyan: #1eaedb;
|
},
|
||||||
--ps-black: #000000;
|
)
|
||||||
--ps-white: #ffffff;
|
|
||||||
--ink: #1f1f1f;
|
|
||||||
--muted: #6b6b6b;
|
|
||||||
--line: #f3f3f3;
|
|
||||||
--ok: #0f7a4f;
|
|
||||||
--ok-bg: #e9f9f1;
|
|
||||||
--off: #9f2f2f;
|
|
||||||
--off-bg: #feefef;
|
|
||||||
}}
|
|
||||||
* {{ box-sizing: border-box; }}
|
|
||||||
body {{
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
font-family: "PlayStation SST", "SST", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
|
||||||
background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 100%);
|
|
||||||
color: var(--ink);
|
|
||||||
}}
|
|
||||||
.page {{
|
|
||||||
width: 980px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #dfe6ef;
|
|
||||||
border-radius: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
|
|
||||||
}}
|
|
||||||
.hero {{
|
|
||||||
padding: 28px 30px 24px 30px;
|
|
||||||
background: linear-gradient(180deg, #121314 0%, #000000 100%);
|
|
||||||
color: var(--ps-white);
|
|
||||||
}}
|
|
||||||
.hero h1 {{
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 44px;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: .1px;
|
|
||||||
}}
|
|
||||||
.hero-sub {{
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: rgba(255,255,255,.86);
|
|
||||||
}}
|
|
||||||
.hero-meta {{
|
|
||||||
margin-top: 14px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}}
|
|
||||||
.meta-pill {{
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: rgba(255,255,255,0.10);
|
|
||||||
border: 1px solid rgba(255,255,255,0.16);
|
|
||||||
}}
|
|
||||||
.meta-pill strong {{ font-weight: 700; }}
|
|
||||||
.content {{
|
|
||||||
padding: 22px 24px 20px 24px;
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%);
|
|
||||||
}}
|
|
||||||
.block {{
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.06) 0 5px 9px 0;
|
|
||||||
}}
|
|
||||||
.block h2 {{
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: .1px;
|
|
||||||
color: #000;
|
|
||||||
}}
|
|
||||||
.feature-list {{
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
/* 图片渲染场景固定三列:不做多端兼容折叠,确保单张图信息密度稳定。 */
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}}
|
|
||||||
.feature-card {{
|
|
||||||
border: 1px solid #e6edf5;
|
|
||||||
border-radius: 19px;
|
|
||||||
padding: 12px 13px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
|
|
||||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
|
||||||
}}
|
|
||||||
.feature-card:hover {{
|
|
||||||
transform: scale(1.02);
|
|
||||||
border-color: var(--ps-cyan);
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.16) 0 5px 9px 0;
|
|
||||||
}}
|
|
||||||
.feature-top {{ display: flex; align-items: center; gap: 8px; }}
|
|
||||||
.feature-index {{
|
|
||||||
min-width: 38px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ps-blue);
|
|
||||||
background: #eef6ff;
|
|
||||||
border: 1px solid #d3e8ff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
}}
|
|
||||||
.feature-meta {{ flex: 1; }}
|
|
||||||
.feature-meta h3 {{
|
|
||||||
margin: 0 0 2px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: .1px;
|
|
||||||
color: #000;
|
|
||||||
}}
|
|
||||||
.feature-key {{ margin: 0; font-size: 12px; color: var(--muted); }}
|
|
||||||
.status-badge {{
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
}}
|
|
||||||
.status-on {{
|
|
||||||
color: var(--ok);
|
|
||||||
background: var(--ok-bg);
|
|
||||||
border-color: #bde8d2;
|
|
||||||
}}
|
|
||||||
.status-off {{
|
|
||||||
color: var(--off);
|
|
||||||
background: var(--off-bg);
|
|
||||||
border-color: #f3c7c7;
|
|
||||||
}}
|
|
||||||
.feature-body {{
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #f8fbff;
|
|
||||||
border: 1px dashed #d6e6f7;
|
|
||||||
}}
|
|
||||||
.feature-body p {{ margin: 0; font-size: 11px; color: #1f1f1f; line-height: 1.5; }}
|
|
||||||
.line-main {{ color: #1f1f1f; }}
|
|
||||||
.line-sub {{ color: #6b6b6b; }}
|
|
||||||
code {{
|
|
||||||
background: #eef6ff;
|
|
||||||
border: 1px solid #d2e6ff;
|
|
||||||
color: #0068bd;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 9px;
|
|
||||||
}}
|
|
||||||
.footer {{
|
|
||||||
background: var(--ps-blue);
|
|
||||||
color: #fff;
|
|
||||||
padding: 14px 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
}}
|
|
||||||
.footer a {{
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px dashed rgba(255,255,255,.55);
|
|
||||||
}}
|
|
||||||
/* 这里故意不保留响应式列数切换:
|
|
||||||
用户需求是“用于生成图片”,统一固定三列更可控。 */
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header class="hero">
|
|
||||||
<h1>机器人功能菜单</h1>
|
|
||||||
<p class="hero-sub">用户功能查看中心 · 直达命令与状态一屏可见</p>
|
|
||||||
<div class="hero-meta">
|
|
||||||
<span class="meta-pill">目标:<strong>{html.escape(group_id)}</strong></span>
|
|
||||||
<span class="meta-pill">功能总数:<strong>{len(user_features)}</strong></span>
|
|
||||||
<span class="meta-pill">已开启:<strong>{enabled_count}</strong></span>
|
|
||||||
<span class="meta-pill">生成时间:<strong>{now_text}</strong></span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="content">
|
|
||||||
<section class="block">
|
|
||||||
<h2>功能卡片</h2>
|
|
||||||
<div class="feature-list">
|
|
||||||
{''.join(feature_cards)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer class="footer">
|
|
||||||
<span>PlayStation Blue UI · 功能查看体验重构</span>
|
|
||||||
<a href="javascript:void(0)">菜单</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def send_menu_content(
|
async def send_menu_content(
|
||||||
self,
|
self,
|
||||||
|
|||||||
234
plugins/robot_menu/templates/menu_cards.html
Normal file
234
plugins/robot_menu/templates/menu_cards.html
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ps-blue: #0070cc;
|
||||||
|
--ps-cyan: #1eaedb;
|
||||||
|
--ps-white: #ffffff;
|
||||||
|
--ink: #1f1f1f;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--line: #f3f3f3;
|
||||||
|
--ok: #0f7a4f;
|
||||||
|
--ok-bg: #e9f9f1;
|
||||||
|
--off: #9f2f2f;
|
||||||
|
--off-bg: #feefef;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: "PlayStation SST", "SST", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||||
|
background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe6ef;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 28px 30px 24px 30px;
|
||||||
|
background: linear-gradient(180deg, #121314 0%, #000000 100%);
|
||||||
|
color: var(--ps-white);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: .1px;
|
||||||
|
}
|
||||||
|
.hero-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255,255,255,.86);
|
||||||
|
}
|
||||||
|
.hero-meta {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.meta-pill {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
border: 1px solid rgba(255,255,255,0.16);
|
||||||
|
}
|
||||||
|
.meta-pill strong { font-weight: 700; }
|
||||||
|
.content {
|
||||||
|
padding: 22px 24px 20px 24px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%);
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.06) 0 5px 9px 0;
|
||||||
|
}
|
||||||
|
.block h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: .1px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.feature-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 19px;
|
||||||
|
padding: 12px 13px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
border-color: var(--ps-cyan);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.16) 0 5px 9px 0;
|
||||||
|
}
|
||||||
|
.feature-top { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.feature-index {
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ps-blue);
|
||||||
|
background: #eef6ff;
|
||||||
|
border: 1px solid #d3e8ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.feature-meta { flex: 1; }
|
||||||
|
.feature-meta h3 {
|
||||||
|
margin: 0 0 2px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: .1px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.feature-key { margin: 0; font-size: 12px; color: var(--muted); }
|
||||||
|
.status-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-on {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-bg);
|
||||||
|
border-color: #bde8d2;
|
||||||
|
}
|
||||||
|
.status-off {
|
||||||
|
color: var(--off);
|
||||||
|
background: var(--off-bg);
|
||||||
|
border-color: #f3c7c7;
|
||||||
|
}
|
||||||
|
.feature-body {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px dashed #d6e6f7;
|
||||||
|
}
|
||||||
|
.feature-body p { margin: 0; font-size: 11px; color: #1f1f1f; line-height: 1.5; }
|
||||||
|
.line-main { color: #1f1f1f; }
|
||||||
|
.line-sub { color: #6b6b6b; display: inline-block; margin-top: 5px; }
|
||||||
|
code {
|
||||||
|
background: #eef6ff;
|
||||||
|
border: 1px solid #d2e6ff;
|
||||||
|
color: #0068bd;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background: var(--ps-blue);
|
||||||
|
color: #fff;
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed rgba(255,255,255,.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<h1>机器人功能菜单</h1>
|
||||||
|
<p class="hero-sub">用户功能查看中心 · 直达命令与状态一屏可见</p>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span class="meta-pill">目标:<strong>{{ group_id }}</strong></span>
|
||||||
|
<span class="meta-pill">功能总数:<strong>{{ feature_total }}</strong></span>
|
||||||
|
<span class="meta-pill">已开启:<strong>{{ enabled_count }}</strong></span>
|
||||||
|
<span class="meta-pill">生成时间:<strong>{{ now_text }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<section class="block">
|
||||||
|
<h2>功能卡片</h2>
|
||||||
|
<div class="feature-list">
|
||||||
|
{% for item in feature_cards %}
|
||||||
|
<section class="feature-card">
|
||||||
|
<div class="feature-top">
|
||||||
|
<div class="feature-index">{{ "%02d"|format(item.index) }}</div>
|
||||||
|
<div class="feature-meta">
|
||||||
|
<h3>{{ item.title }}</h3>
|
||||||
|
<p class="feature-key">功能键:<code>{{ item.feature_key }}</code></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge {{ item.status_class }}">{{ item.status_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-body">
|
||||||
|
{% if item.command.is_auto %}
|
||||||
|
<p><span class="line-main">{{ item.command.tip }}</span></p>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
<span class="line-main">指令:<code>{{ item.command.primary }}</code></span>
|
||||||
|
{% if item.command.aliases %}
|
||||||
|
<span class="line-sub">
|
||||||
|
别名:
|
||||||
|
{% for alias in item.command.aliases %}
|
||||||
|
<code>{{ alias }}</code>{% if not loop.last %} / {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<span>ABOT 功能菜单</span>
|
||||||
|
<a href="javascript:void(0)">菜单</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
utils/html_template_renderer.py
Normal file
42
utils/html_template_renderer.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""通用 HTML 模板渲染工具。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
1. 把页面结构/样式从 Python 代码中抽离到 .html 模板文件;
|
||||||
|
2. Python 只负责组装数据,模板负责展示;
|
||||||
|
3. 后续改 UI 时只改模板文件,不改业务代码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, select_autoescape
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlTemplateRenderer:
|
||||||
|
"""基于 Jinja2 的 HTML 模板渲染器。"""
|
||||||
|
|
||||||
|
def __init__(self, template_root: Path = None):
|
||||||
|
# 默认以仓库根目录作为模板根路径,确保可通过相对路径定位模板文件。
|
||||||
|
root = template_root or Path(__file__).resolve().parents[1]
|
||||||
|
self.template_root = Path(root).resolve()
|
||||||
|
self.env = Environment(
|
||||||
|
loader=FileSystemLoader(str(self.template_root)),
|
||||||
|
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, template_path: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""渲染模板并返回完整 HTML 字符串。"""
|
||||||
|
normalized = str(template_path or "").replace("\\", "/").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("template_path 不能为空")
|
||||||
|
try:
|
||||||
|
template = self.env.get_template(normalized)
|
||||||
|
except TemplateNotFound as e:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"模板文件不存在: {normalized} (root={self.template_root})"
|
||||||
|
) from e
|
||||||
|
return template.render(**(context or {}))
|
||||||
|
|
||||||
Reference in New Issue
Block a user