Files
zhenxun_bot/zhenxun/services/renderer/template.py
T
ce94f63d9a ✨ feat(hook): 增强认证钩子和运行时缓存管理 (#2106)
* ✨ feat(hook): 增强认证钩子和运行时缓存管理
```
♻ refactor(hook): 移除未使用的配置项并优化缓存设置

移除 AUTH_HOOKS_CONCURRENCY_LIMIT 配置项,该配置项不再使用

✨feat(auth_ban): 简化缓存配置并添加实体参数支持

将 BAN_CACHE_TTL 相关配置从动态配置改为常量定义,
移除复杂的 TTL 值转换逻辑,并为 auth_ban 函数添加可选的
entity 参数以支持外部传入实体信息

♻ refactor(auth_limit): 移除未使用的配置依赖

移除 AUTH_LIMIT_NOTICE_CD 配置项,直接使用常量值 2
作为限制通知冷却时间

📦 依赖更新: update playwright dependency to version 1.57.0 in pyproject.toml and requirements.txt
```

* ✨ feat(auth_checker): 增强插件模块处理和预过滤逻辑,支持用户插件兼容性

* ✨ feat(http_utils): 添加内容缓存机制以优化HTTP响应处理

* ✨ feat(cache): 添加群组插件设置视图缓存类型并更新相关逻辑

* ✨ feat(renderer): 优化渲染引擎,增加内存缓存管理和HTML文档处理逻辑

* ✨ feat(sign_in): 添加好感度排行和好感度总排行命令

* ✨ feat(renderer): 增强浏览器实例管理和模板预处理,支持历史 include 语法兼容

* ✨ feat(renderer): 优化 Playwright 环境检查逻辑,增加结果缓存以提高性能

* ✨ feat(renderer): 增强模板文件渲染策略,优化资产加载路径处理

* 🚨 auto fix by pre-commit hooks

* ✨ feat(log): 增加日志内容安全序列化,避免超长 base64 等污染日志
✨ feat(log_sanitizer): 添加对嵌入超长 base64/data URI 的清理功能
✨ feat(auth_checker): 添加 Alconna 快捷方式缓存检查,优化路由匹配逻辑

* feat(renderer): 添加渲染结果内存缓存功能以优化性能
feat(help): 实现帮助菜单图像缓存机制
feat(sign_in): 更新HTML卡片生成以支持动画禁用和剪裁
feat(superuser): 在启动时预热超级用户帮助缓存
feat(theme): 优化主题管理器的资源解析缓存

* 🚨 auto fix by pre-commit hooks

* feat(renderer): 增加全页面视口最大宽度限制并优化内容尺寸计算

---------

Co-authored-by: ATTomatoo <1126160939@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2026-03-03 15:54:47 +08:00

321 lines
12 KiB
Python

from collections.abc import Callable
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING, Any
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
select_autoescape,
)
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.theme import DependencyCollector
from zhenxun.services.renderer.types import (
RESERVED_TEMPLATE_KEYS,
Renderable,
RenderResult,
RenderStrategy,
)
from zhenxun.utils.exception import RenderingError
if TYPE_CHECKING:
from .types import RenderContext
_LEGACY_INCLUDE_TAG_PATTERN = re.compile(
r"\{\s*%\s*include\s+([\"'][^\"']+[\"'])\s*%\s*\}",
flags=re.IGNORECASE,
)
class RelativePathEnvironment(Environment):
"""
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
"""
def join_path(self, template: str, parent: str) -> str:
if template.startswith("./") or template.startswith("../"):
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
return path.replace(os.path.sep, "/")
return super().join_path(template, parent)
def preprocess(
self,
source: str,
name: str | None = None,
filename: str | None = None,
) -> str:
"""
兼容历史模板里的错误 include 语法:
{
% include './style.css' %
}
自动归一化为标准 Jinja 语法,避免页面样式丢失。
"""
normalized_source = _LEGACY_INCLUDE_TAG_PATTERN.sub(r"{% include \1 %}", source)
return super().preprocess(normalized_source, name, filename)
class JinjaTemplateEngine:
"""
负责 HTML 生成的核心引擎。
"""
def __init__(
self, plugin_template_paths: dict[str, Path], auto_reload: bool = False
):
self._plugin_template_paths = plugin_template_paths
self._auto_reload = auto_reload
self.env = self._create_jinja_env()
def _create_jinja_env(self) -> Environment:
"""创建并配置 Jinja2 渲染环境"""
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = RelativePathEnvironment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
auto_reload=self._auto_reload,
)
return env
def update_theme_loaders(self, theme_dir: Path):
"""更新 Loader 以支持多主题"""
if self.env.loader and isinstance(self.env.loader, ChoiceLoader):
current_loaders = list(self.env.loader.loaders)
if len(current_loaders) > 1 and isinstance(
current_loaders[0], PrefixLoader
):
prefix_loader = current_loaders[0]
new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.env.loader.loaders = [prefix_loader, new_theme_loader]
def set_global(self, key: str, value: Any):
self.env.globals[key] = value
def set_filter(self, key: str, func: Callable):
self.env.filters[key] = func
async def render_component_to_html(
self,
component: Renderable,
template_name: str,
theme_context: dict,
theme_css_content: str,
inline_css: list[str],
scripts: list[str],
styles: list[str],
**kwargs,
) -> str:
"""
将组件数据和模板结合,生成最终的 HTML 字符串。
"""
logger.debug(
f"正在渲染组件模板: '{template_name}'",
"JinjaTemplateEngine",
)
template = self.env.get_template(template_name)
data_dict = component.get_render_data()
unpacked_data = {}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{template_name}' 中请使用 'data.{key}' 访问。"
)
else:
unpacked_data[key] = value
template_context = {
"data": component,
"theme": theme_context,
"frameless": True,
}
template_context.update(unpacked_data)
template_context.update({k: v for k, v in kwargs.items() if k != "frameless"})
html_fragment = await template.render_async(**template_context)
if not kwargs.get("frameless", False):
base_template = self.env.get_template("partials/_base.html")
page_context = {
"data": component,
"theme_css": theme_css_content,
"collected_inline_css": inline_css,
"required_scripts": scripts,
"collected_asset_styles": styles,
"body_content": html_fragment,
}
return await base_template.render_async(**page_context)
else:
# 对于本身已是完整 HTML 文档的模板,直接返回,避免重复拼接大段 CSS。
html_head = html_fragment.lstrip()[:32].lower()
if html_head.startswith("<!doctype html") or html_head.startswith("<html"):
return html_fragment
style_blocks: list[str] = []
if theme_css_content:
style_blocks.append(theme_css_content)
if inline_css:
style_blocks.extend(inline_css)
if style_blocks:
css_content = "\n".join(style_blocks)
return f"<style>{css_content}</style>\n{html_fragment}"
return html_fragment
class ComponentRenderStrategy(RenderStrategy):
"""标准组件渲染策略。"""
async def render(self, context: "RenderContext") -> RenderResult:
component = context.component
await component.prepare()
await DependencyCollector.collect(component, context)
data_dict = component.get_render_data()
_opts = data_dict.get("render_options")
component_render_options = _opts if isinstance(_opts, dict) else {}
component_template_identifier = str(
getattr(component, "template_path", None) or component.template_name
)
variant = getattr(component, "variant", None)
manifest_options = {}
if manifest := await context.theme_manager.get_template_manifest(
component_template_identifier, skin=variant
):
manifest_options = manifest.render_options
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(context.render_options)
if getattr(component, "is_page", False):
final_render_options["frameless"] = True
if not context.theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
resolved_template_name = await context.theme_manager.resolve_component_template(
component, context
)
theme_css_template = context.template_engine.env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=context.theme_manager.current_theme_context
)
html_content = await context.template_engine.render_component_to_html(
component,
resolved_template_name,
context.theme_manager.current_theme_context,
theme_css_content,
context.collected_inline_css,
list(context.collected_scripts),
list(context.collected_asset_styles),
**final_render_options,
)
screenshot_options = final_render_options.copy()
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
screenshot_options.pop("_keep_html_content", None)
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
keep_html_content = bool(
context.render_options.get("_keep_html_content", False)
)
debug_mode = Config.get_config("UI", "DEBUG_MODE", False)
return RenderResult(
image_bytes=image_bytes,
html_content=html_content if debug_mode or keep_html_content else None,
)
class TemplateFileRenderStrategy(RenderStrategy):
"""独立模板文件渲染策略。"""
async def render(self, context: "RenderContext") -> RenderResult:
component = context.component
template_path = getattr(component, "template_path")
await component.prepare()
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
temp_loader = FileSystemLoader(str(template_dir))
base_loader = context.template_engine.env.loader
temp_env_loader = (
ChoiceLoader([temp_loader, base_loader]) if base_loader else temp_loader
)
temp_env = RelativePathEnvironment(
loader=temp_env_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals.update(context.template_engine.env.globals)
temp_env.filters.update(context.template_engine.env.filters)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.globals["random_asset"] = (
context.theme_manager.create_random_asset_loader()
)
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
template_context = {
"theme": context.theme_manager.current_theme_context,
"data": data_dict,
}
for key, value in data_dict.items():
if key not in RESERVED_TEMPLATE_KEYS:
template_context[key] = value
html_content = await template.render_async(**template_context)
_opts = data_dict.get("render_options")
component_render_options = _opts if isinstance(_opts, dict) else {}
final_render_options = component_render_options.copy()
final_render_options.update(context.render_options)
if getattr(component, "is_page", False):
final_render_options["frameless"] = True
screenshot_options = final_render_options.copy()
screenshot_options.pop("_keep_html_content", None)
image_bytes = await context.screenshot_engine.render(
html=html_content, base_url_path=template_dir, **screenshot_options
)
keep_html_content = bool(
context.render_options.get("_keep_html_content", False)
)
debug_mode = Config.get_config("UI", "DEBUG_MODE", False)
return RenderResult(
image_bytes=image_bytes,
html_content=html_content if debug_mode or keep_html_content else None,
)