mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-29 00:32:06 +08:00
* ♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理 - 【重构】重构 `RendererService` 架构,解耦模板引擎、主题管理与截图引擎 - 【重构】重构 `ui` 模块,采用组件注册机制与数据模型驱动,移除旧版 `builders` - 【功能】新增 UI 热重载模式,支持在不重启的情况下实时预览 HTML/CSS 修改 - 【功能】增强启动项资源检查,支持基于 `resources.spec` 的版本校验与自动更新 - 【优化】统一内置插件的 UI 渲染逻辑,迁移至新的 `ui.table`、`ui.markdown` 等工厂接口 - 【优化】优化日志脱敏工具,支持自动折叠调试输出中冗长的样式标签 - 【优化】引入 `AssetResolutionService`,完善皮肤、组件、主题间的多级资源回退机制 - 【优化】新增组件生命周期钩子 `prepare`,支持渲染前的异步数据预处理 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
513 lines
20 KiB
Python
513 lines
20 KiB
Python
import asyncio
|
|
from collections.abc import Awaitable, Callable
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
import aiofiles
|
|
from jinja2 import TemplateNotFound
|
|
from nonebot.utils import is_coroutine_callable
|
|
import ujson as json
|
|
|
|
from zhenxun.configs.config import Config
|
|
from zhenxun.configs.path_config import UI_CACHE_PATH
|
|
from zhenxun.services.log import logger
|
|
from zhenxun.services.renderer.template import (
|
|
ComponentRenderStrategy,
|
|
JinjaTemplateEngine,
|
|
TemplateFileRenderStrategy,
|
|
)
|
|
from zhenxun.services.renderer.theme import DependencyCollector, asset_registry
|
|
from zhenxun.services.renderer.types import (
|
|
BaseScreenshotEngine,
|
|
Renderable,
|
|
RenderContext,
|
|
RenderResult,
|
|
RenderStrategy,
|
|
)
|
|
from zhenxun.utils.exception import RenderingError
|
|
from zhenxun.utils.log_sanitizer import sanitize_for_logging
|
|
from zhenxun.utils.pydantic_compat import _dump_pydantic_obj
|
|
|
|
from .engine import engine_manager
|
|
from .theme import ThemeManager
|
|
|
|
|
|
class RendererService:
|
|
"""
|
|
图片渲染服务的统一门面。
|
|
|
|
作为UI渲染的中心枢纽,负责编排和调用底层服务,提供统一的渲染接口。
|
|
主要职责包括:
|
|
- 管理和加载UI主题 (通过 ThemeManager)。
|
|
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML。
|
|
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片。
|
|
- 处理插件注册的模板、过滤器和全局函数。
|
|
- (可选) 管理渲染结果的缓存。
|
|
"""
|
|
|
|
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
|
|
|
|
def __init__(self):
|
|
self._template_engine: JinjaTemplateEngine | None = None
|
|
self._theme_manager: ThemeManager | None = None
|
|
self._screenshot_engine: BaseScreenshotEngine | None = None
|
|
self._initialized = False
|
|
self._init_lock = asyncio.Lock()
|
|
self._custom_filters: dict[str, Callable] = {}
|
|
self._custom_globals: dict[str, Callable] = {}
|
|
|
|
self.filter("dump_json")(self._pydantic_tojson_filter)
|
|
self.global_function("inline_asset")(self._inline_asset_global)
|
|
|
|
def register_template_namespace(self, namespace: str, path: Path):
|
|
"""
|
|
为插件注册一个Jinja2模板命名空间。
|
|
|
|
这允许插件在自己的目录中维护模板,并通过
|
|
`{% include '@namespace/template.html' %}` 的方式引用它们,
|
|
避免了与核心或其他插件的模板命名冲突。
|
|
|
|
参数:
|
|
namespace: 插件的唯一命名空间,建议使用插件模块名
|
|
path: 包含该插件模板的目录路径
|
|
|
|
异常:
|
|
ValueError: 当提供的路径不是有效目录时抛出
|
|
"""
|
|
if namespace in self._plugin_template_paths:
|
|
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
|
|
if not path.is_dir():
|
|
raise ValueError(f"提供的路径 '{path}' 不是一个有效的目录。")
|
|
self._plugin_template_paths[namespace] = path
|
|
|
|
def register_markdown_style(self, name: str, path: Path):
|
|
"""
|
|
为 Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)。
|
|
|
|
参数:
|
|
name (str): 样式的唯一名称,例如 'cyberpunk'。
|
|
path (Path): 指向该样式的CSS文件路径。
|
|
"""
|
|
if not path.is_file():
|
|
raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。")
|
|
asset_registry.register_markdown_style(name, path)
|
|
|
|
def filter(self, name: str) -> Callable:
|
|
"""
|
|
装饰器:注册一个自定义 Jinja2 过滤器。
|
|
|
|
参数:
|
|
name: 过滤器在模板中的调用名称。
|
|
|
|
返回:
|
|
Callable: 用于装饰过滤器函数的装饰器。
|
|
"""
|
|
|
|
def decorator(func: Callable) -> Callable:
|
|
if name in self._custom_filters:
|
|
logger.warning(f"Jinja2 过滤器 '{name}' 已被注册,将被覆盖。")
|
|
self._custom_filters[name] = func
|
|
logger.debug(f"已注册自定义 Jinja2 过滤器: '{name}'")
|
|
return func
|
|
|
|
return decorator
|
|
|
|
def global_function(self, name: str) -> Callable:
|
|
"""
|
|
装饰器:注册一个自定义 Jinja2 全局函数。
|
|
|
|
参数:
|
|
name: 函数在模板中的调用名称。
|
|
|
|
返回:
|
|
Callable: 用于装饰全局函数的装饰器。
|
|
"""
|
|
|
|
def decorator(func: Callable) -> Callable:
|
|
if name in self._custom_globals:
|
|
logger.warning(f"Jinja2 全局函数 '{name}' 已被注册,将被覆盖。")
|
|
self._custom_globals[name] = func
|
|
logger.debug(f"已注册自定义 Jinja2 全局函数: '{name}'")
|
|
return func
|
|
|
|
return decorator
|
|
|
|
async def _inline_asset_global(self, namespaced_path: str) -> str:
|
|
"""
|
|
一个Jinja2全局函数,用于读取并内联一个已注册命名空间下的资源文件内容。
|
|
主要用于内联SVG,以解决浏览器的跨域安全问题。
|
|
"""
|
|
if not self._template_engine or not self._template_engine.env.loader:
|
|
return f"<!-- Error: Jinja env not ready for {namespaced_path} -->"
|
|
try:
|
|
source, _, _ = self._template_engine.env.loader.get_source(
|
|
self._template_engine.env, namespaced_path
|
|
)
|
|
return source
|
|
except TemplateNotFound:
|
|
return f"<!-- Asset not found: {namespaced_path} -->"
|
|
|
|
async def initialize(self):
|
|
"""
|
|
延迟初始化方法,在 on_startup 钩子中调用。
|
|
|
|
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
|
|
使用锁来防止并发初始化。
|
|
"""
|
|
if self._initialized:
|
|
return
|
|
async with self._init_lock:
|
|
if self._initialized:
|
|
return
|
|
|
|
try:
|
|
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
|
|
self._template_engine = JinjaTemplateEngine(
|
|
self._plugin_template_paths, auto_reload=hot_reload
|
|
)
|
|
|
|
self._template_engine.env.filters.update(self._custom_filters)
|
|
self._template_engine.env.globals.update(self._custom_globals)
|
|
|
|
self._theme_manager = ThemeManager()
|
|
self._theme_manager.bind_template_engine(self._template_engine.env)
|
|
|
|
self._template_engine.set_global(
|
|
"render", self._theme_manager._global_render_component
|
|
)
|
|
self._template_engine.set_global(
|
|
"asset", self._theme_manager.create_asset_loader()
|
|
)
|
|
self._template_engine.set_global(
|
|
"random_asset", self._theme_manager.create_random_asset_loader()
|
|
)
|
|
self._template_engine.set_global(
|
|
"resolve_template", self._theme_manager.resolve_component_template
|
|
)
|
|
self._template_engine.set_filter(
|
|
"md", self._theme_manager._markdown_filter
|
|
)
|
|
|
|
self._screenshot_engine = await engine_manager.get_engine()
|
|
|
|
current_theme_name = Config.get_config("UI", "THEME", "default")
|
|
await self._theme_manager.load_theme(current_theme_name)
|
|
|
|
if self._theme_manager.current_theme:
|
|
self._template_engine.update_theme_loaders(
|
|
self._theme_manager.current_theme.assets_dir.parent
|
|
)
|
|
|
|
self._template_engine.set_global(
|
|
"theme", self._theme_manager.current_theme_context
|
|
)
|
|
self._template_engine.set_global(
|
|
"default_theme_palette", self._theme_manager.current_default_palette
|
|
)
|
|
|
|
self._initialized = True
|
|
except Exception as e:
|
|
logger.error(
|
|
f"渲染服务初始化失败,UI功能将不可用: {e}", "RendererService"
|
|
)
|
|
|
|
async def _collect_dependencies_recursive(
|
|
self, component: Renderable, context: "RenderContext"
|
|
):
|
|
"""
|
|
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
|
|
"""
|
|
await DependencyCollector.collect(component, context)
|
|
|
|
async def _render_component(
|
|
self,
|
|
context: "RenderContext",
|
|
) -> RenderResult:
|
|
"""
|
|
执行完整的组件渲染流程。
|
|
包含缓存检查、组件生命周期调用、依赖收集、HTML生成、截图以及缓存写入。
|
|
"""
|
|
return await self._apply_caching_layer(self._render_component_core, context)
|
|
|
|
async def _apply_caching_layer(
|
|
self,
|
|
core_render_func: Callable[..., Awaitable[RenderResult]],
|
|
context: "RenderContext",
|
|
) -> RenderResult:
|
|
"""
|
|
一个高阶函数,为核心渲染逻辑提供缓存层。
|
|
它负责处理缓存的读取和写入,而将实际的渲染工作委托给传入的函数。
|
|
"""
|
|
cache_path = None
|
|
component = context.component
|
|
|
|
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
|
|
if Config.get_config("UI", "CACHE") and context.use_cache and not hot_reload:
|
|
try:
|
|
template_name = str(
|
|
getattr(component, "template_path", None) or component.template_name
|
|
)
|
|
data_dict = component.get_render_data()
|
|
resolved_data_dict = {}
|
|
for key, value in data_dict.items():
|
|
if is_coroutine_callable(value): # type: ignore
|
|
resolved_data_dict[key] = await value
|
|
else:
|
|
resolved_data_dict[key] = value
|
|
data_str = json.dumps(resolved_data_dict, sort_keys=True)
|
|
cache_key_str = f"{template_name}:{data_str}"
|
|
cache_filename = (
|
|
f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png"
|
|
)
|
|
cache_path = UI_CACHE_PATH / cache_filename
|
|
|
|
if cache_path.exists():
|
|
logger.debug(f"UI缓存命中: {cache_path}")
|
|
async with aiofiles.open(cache_path, "rb") as f:
|
|
image_bytes = await f.read()
|
|
return RenderResult(
|
|
image_bytes=image_bytes, html_content="<!-- from cache -->"
|
|
)
|
|
logger.debug(f"UI缓存未命中: {cache_key_str[:100]}...")
|
|
except Exception as e:
|
|
logger.warning(f"UI缓存读取失败: {e}", e=e)
|
|
cache_path = None
|
|
|
|
result = await core_render_func(context)
|
|
|
|
if (
|
|
Config.get_config("UI", "CACHE")
|
|
and context.use_cache
|
|
and not hot_reload
|
|
and cache_path
|
|
and result.image_bytes
|
|
):
|
|
try:
|
|
async with aiofiles.open(cache_path, "wb") as f:
|
|
await f.write(result.image_bytes)
|
|
logger.debug(f"UI缓存写入成功: {cache_path}")
|
|
except Exception as e:
|
|
logger.warning(f"UI缓存写入失败: {e}", e=e)
|
|
|
|
return result
|
|
|
|
def _select_strategy(self, component: Renderable) -> RenderStrategy:
|
|
"""
|
|
根据组件特性选择合适的渲染策略。
|
|
"""
|
|
if getattr(component, "_is_standalone_template", False):
|
|
return TemplateFileRenderStrategy()
|
|
|
|
template_path = getattr(component, "template_path", None)
|
|
if isinstance(template_path, Path) and template_path.is_absolute():
|
|
return TemplateFileRenderStrategy()
|
|
|
|
return ComponentRenderStrategy()
|
|
|
|
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
|
|
"""
|
|
不含缓存处理的核心渲染逻辑,负责调度具体的渲染策略。
|
|
"""
|
|
try:
|
|
if not self._initialized:
|
|
await self.initialize()
|
|
if not self._initialized or not context.screenshot_engine:
|
|
raise RenderingError(
|
|
"渲染服务未正确初始化(可能缺少资源文件),无法渲染组件。"
|
|
)
|
|
|
|
strategy = self._select_strategy(context.component)
|
|
return await strategy.render(context)
|
|
|
|
except RenderingError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"渲染组件 '{context.component.__class__.__name__}' 时发生错误",
|
|
"RendererService",
|
|
e=e,
|
|
)
|
|
raise RenderingError(
|
|
f"渲染组件 '{context.component.__class__.__name__}' 失败"
|
|
) from e
|
|
|
|
async def render(
|
|
self, component: Renderable, use_cache: bool = False, **render_options
|
|
) -> bytes:
|
|
"""
|
|
将组件渲染为图片字节数据。
|
|
|
|
参数:
|
|
component: 需要渲染的 Renderable 组件实例
|
|
use_cache: 是否启用渲染缓存,默认为 False
|
|
**render_options: 传递给底层截图引擎的额外参数,如 `viewport` (字典), `device_scale_factor` 等
|
|
|
|
返回:
|
|
bytes: 渲染后的PNG图片二进制数据
|
|
|
|
异常:
|
|
RenderingError: 当渲染流程中任何步骤(初始化、资源缺失、截图失败)发生错误时抛出
|
|
""" # noqa: E501
|
|
if not self._initialized:
|
|
await self.initialize()
|
|
if (
|
|
not self._initialized
|
|
or not self._theme_manager
|
|
or not self._screenshot_engine
|
|
or not self._template_engine
|
|
):
|
|
raise RenderingError(
|
|
"渲染服务未正确初始化(可能缺少资源文件),无法生成图片。"
|
|
)
|
|
|
|
context = RenderContext(
|
|
renderer=self,
|
|
theme_manager=self._theme_manager,
|
|
template_engine=self._template_engine,
|
|
screenshot_engine=self._screenshot_engine,
|
|
component=component,
|
|
use_cache=use_cache,
|
|
render_options=render_options,
|
|
)
|
|
result = await self._render_component(context)
|
|
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
|
|
sanitized_html = sanitize_for_logging(
|
|
result.html_content, context="ui_html"
|
|
)
|
|
logger.info(
|
|
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
|
|
f"{sanitized_html}\n"
|
|
f"--- [UI DEBUG] End of HTML ---"
|
|
)
|
|
if result.image_bytes is None:
|
|
raise RenderingError("渲染成功但未能生成图片字节数据。")
|
|
return result.image_bytes
|
|
|
|
async def render_to_html(
|
|
self, component: Renderable, frameless: bool = False
|
|
) -> str:
|
|
"""
|
|
调试方法:只执行到HTML生成步骤,不进行截图。
|
|
|
|
参数:
|
|
component: 一个 `Renderable` 实例。
|
|
frameless: 是否以无边框模式渲染(只渲染HTML片段)。
|
|
|
|
返回:
|
|
str: 最终渲染出的完整HTML字符串。
|
|
"""
|
|
if not self._initialized:
|
|
await self.initialize()
|
|
if (
|
|
not self._initialized
|
|
or not self._theme_manager
|
|
or not self._template_engine
|
|
or not self._screenshot_engine
|
|
):
|
|
raise RenderingError("渲染服务未正确初始化(可能缺少资源文件)。")
|
|
|
|
context = RenderContext(
|
|
renderer=self,
|
|
theme_manager=self._theme_manager,
|
|
template_engine=self._template_engine,
|
|
screenshot_engine=self._screenshot_engine,
|
|
component=component,
|
|
use_cache=False,
|
|
render_options={"frameless": frameless},
|
|
)
|
|
await component.prepare()
|
|
await DependencyCollector.collect(component, context)
|
|
|
|
resolved_template_name = await self._theme_manager.resolve_component_template(
|
|
component, context
|
|
)
|
|
theme_css_template = self._template_engine.env.get_template("theme.css.jinja")
|
|
theme_css_content = await theme_css_template.render_async(
|
|
theme=self._theme_manager.current_theme_context
|
|
)
|
|
|
|
return await self._template_engine.render_component_to_html(
|
|
component,
|
|
resolved_template_name,
|
|
self._theme_manager.current_theme_context,
|
|
theme_css_content,
|
|
context.collected_inline_css,
|
|
list(context.collected_scripts),
|
|
list(context.collected_asset_styles),
|
|
frameless=frameless,
|
|
)
|
|
|
|
async def reload_theme(self) -> str:
|
|
"""
|
|
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
|
|
这在开发主题时非常有用,可以热重载主题更改。
|
|
|
|
返回:
|
|
str: 已成功加载的主题名称。
|
|
"""
|
|
if not self._initialized:
|
|
await self.initialize()
|
|
if not self._initialized or not self._theme_manager:
|
|
raise RenderingError(
|
|
"渲染服务未正确初始化(可能缺少资源文件),无法重新加载主题。"
|
|
)
|
|
|
|
if self._theme_manager.manifest_registry:
|
|
self._theme_manager.manifest_registry.clear_cache()
|
|
logger.debug("已清除UI清单缓存 (manifest cache)。")
|
|
current_theme_name = Config.get_config("UI", "THEME", "default")
|
|
await self._theme_manager.load_theme(current_theme_name)
|
|
|
|
if self._theme_manager.current_theme and self._template_engine:
|
|
self._template_engine.update_theme_loaders(
|
|
self._theme_manager.current_theme.assets_dir.parent
|
|
)
|
|
if self._template_engine and self._template_engine.env.cache:
|
|
self._template_engine.env.cache.clear()
|
|
|
|
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
|
|
return current_theme_name
|
|
|
|
def list_available_themes(self) -> list[str]:
|
|
"""获取所有可用主题的列表。"""
|
|
if not self._initialized or not self._theme_manager:
|
|
raise RuntimeError("ThemeManager尚未初始化。")
|
|
return self._theme_manager.list_available_themes()
|
|
|
|
async def switch_theme(self, theme_name: str) -> str:
|
|
"""
|
|
切换UI主题,加载新主题并持久化配置。
|
|
|
|
返回:
|
|
str: 已成功切换到的主题名称。
|
|
"""
|
|
if not self._initialized or not self._theme_manager:
|
|
await self.initialize()
|
|
assert self._theme_manager is not None
|
|
|
|
available_themes = self._theme_manager.list_available_themes()
|
|
if theme_name not in available_themes:
|
|
raise FileNotFoundError(
|
|
f"主题 '{theme_name}' 不存在。可用主题: {', '.join(available_themes)}"
|
|
)
|
|
|
|
await self._theme_manager.load_theme(theme_name)
|
|
|
|
if self._theme_manager.current_theme and self._template_engine:
|
|
self._template_engine.update_theme_loaders(
|
|
self._theme_manager.current_theme.assets_dir.parent
|
|
)
|
|
if self._template_engine and self._template_engine.env.cache:
|
|
self._template_engine.env.cache.clear()
|
|
|
|
Config.set_config("UI", "THEME", theme_name, auto_save=True)
|
|
logger.info(f"UI主题已切换为: {theme_name}")
|
|
return theme_name
|
|
|
|
@staticmethod
|
|
def _pydantic_tojson_filter(obj: Any) -> str:
|
|
"""一个能够递归处理Pydantic模型及其集合的 tojson 过滤器"""
|
|
dumped_obj = _dump_pydantic_obj(obj)
|
|
return json.dumps(dumped_obj, ensure_ascii=False)
|