♻️ refactor(UI): 重构UI渲染服务为组件化分层架构 (#2025)
检查bot是否运行正常 / bot check (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

* ♻️ refactor(UI): 重构UI渲染服务为组件化分层架构

♻️ **架构重构**
- UI渲染服务重构为组件化分层架构
- 解耦主题管理、HTML生成、截图功能

✨ **新增功能**
- `zhenxun.ui` 统一入口,提供 `render`、`markdown`、`vstack` 等API
- `RenderableComponent` 基类和渲染协议抽象
- 新增主题管理器和截图引擎模块

⚙️ **配置优化**
- UI配置迁移至 `superuser/ui_manager.py`
- 新增"重载UI主题"管理指令

🔧 **性能改进**
- 优化渲染缓存,支持组件级透明缓存
- 所有UI组件适配新渲染流程

* 🚨 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>
This commit is contained in:
Rumio
2025-08-18 23:08:22 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent 11524bcb04
commit 6124e217d0
43 changed files with 1334 additions and 928 deletions
+1 -25
View File
@@ -1,37 +1,13 @@
"""
图片渲染服务
提供一个统一的、可扩展的接口来将结构化数据渲染成图片。
"""
from zhenxun.configs.config import Config
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from .service import RendererService
Config.add_plugin_config(
"UI",
"THEME",
"default",
help="设置渲染服务使用的全局主题名称 (对应 resources/themes/下的目录名)",
default_value="default",
type=str,
)
Config.add_plugin_config(
"UI",
"CACHE",
True,
help="是否为渲染服务生成的图片启用文件缓存",
default_value=True,
type=bool,
)
renderer_service = RendererService()
@PriorityLifecycle.on_startup(priority=10)
async def _init_renderer_service():
"""在Bot启动时预热渲染服务,扫描并加载所有模板。"""
"""在Bot启动时初始化渲染服务及其依赖。"""
await renderer_service.initialize()
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
from nonebot_plugin_htmlrender import html_to_pic
from .protocols import ScreenshotEngine
class PlaywrightEngine(ScreenshotEngine):
"""使用 nonebot-plugin-htmlrender 实现的截图引擎。"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
base_url_for_browser = base_url_path.absolute().as_uri()
if not base_url_for_browser.endswith("/"):
base_url_for_browser += "/"
final_render_options = {
"viewport": {"width": 800, "height": 10},
**render_options,
"base_url": base_url_for_browser,
}
return await html_to_pic(
html=html,
template_path=base_url_for_browser,
**final_render_options,
)
def get_screenshot_engine() -> ScreenshotEngine:
"""
截图引擎工厂函数。
目前只返回 PlaywrightEngine, 未来可以根据配置返回不同的引擎。
"""
return PlaywrightEngine()
-221
View File
@@ -1,221 +0,0 @@
from abc import ABC, abstractmethod
from pathlib import Path
import aiofiles
from jinja2 import Environment
import markdown
from nonebot_plugin_htmlrender import html_to_pic
from pydantic import BaseModel
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from .models import Theme
THEME_PATH = THEMES_PATH
RESOURCE_ROOT = THEMES_PATH.parent
class BaseEngine(ABC):
"""渲染引擎的抽象基类。"""
@abstractmethod
async def render(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment | None" = None,
extra_css_paths: list[Path] | None = None,
custom_css_path: Path | None = None,
**kwargs,
) -> bytes:
"""所有引擎都必须实现的渲染方法。"""
pass
class BaseHtmlRenderingEngine(BaseEngine):
"""
一个专门用于处理HTML到图片转换的引擎基类。
它使用模板方法模式,定义了渲染的固定流程,
并将具体的HTML内容生成委托给子类的抽象方法 `get_html_content`。
"""
@abstractmethod
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
"""
[抽象方法] 子类必须实现此方法以生成最终的HTML字符串。
"""
pass
async def render(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment | None" = None,
extra_css_paths: list[Path] | None = None,
custom_css_path: Path | None = None,
**kwargs,
) -> bytes:
"""
[通用渲染流程] 调用 `get_html_content` 获取HTML,然后调用 `html_to_pic` 生成图片
"""
if not jinja_env:
raise ValueError("HTML渲染器需要一个有效的Jinja2环境实例。")
frameless = kwargs.pop("frameless", False)
html_content = await self.get_html_content(
template_name,
data,
theme,
jinja_env,
extra_css_paths,
custom_css_path,
frameless=frameless,
**kwargs,
)
base_url_for_browser = RESOURCE_ROOT.absolute().as_uri()
if not base_url_for_browser.endswith("/"):
base_url_for_browser += "/"
pages_config = {
"viewport": kwargs.pop("viewport", {"width": 800, "height": 10}),
"base_url": base_url_for_browser,
}
final_screenshot_kwargs = kwargs.copy()
final_screenshot_kwargs.update(pages_config)
return await html_to_pic(
html=html_content,
template_path=base_url_for_browser,
**final_screenshot_kwargs,
)
class HtmlRenderer(BaseHtmlRenderingEngine):
"""使用 nonebot-plugin-htmlrender 渲染HTML模板的引擎。"""
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
def asset_loader(asset_path: str) -> str:
current_theme_asset = theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.relative_to(RESOURCE_ROOT).as_posix()
default_theme_asset = theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(RESOURCE_ROOT).as_posix()
logger.warning(
f"资源文件在主题 '{theme.name}' 和 'default' 中均未找到: {asset_path}"
)
return ""
extra_css_content = ""
if extra_css_paths:
css_contents = []
for path in extra_css_paths:
if path.exists():
async with aiofiles.open(path, encoding="utf-8") as f:
css_contents.append(await f.read())
extra_css_content = "\n".join(css_contents)
template_context = {
"data": data,
"extra_css": extra_css_content,
"frameless": frameless,
"theme": {
"name": theme.name,
"palette": theme.palette,
"asset": asset_loader,
},
}
template = jinja_env.get_template(template_name)
return await template.render_async(**template_context)
class MarkdownEngine(BaseHtmlRenderingEngine):
"""在服务端渲染 Markdown 为 HTML,然后截图的引擎。"""
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
if isinstance(data, BaseModel):
raw_md = getattr(data, "markdown", "") if hasattr(data, "markdown") else ""
else:
raw_md = (data or {}).get("markdown", "")
md_html = markdown.markdown(
raw_md,
extensions=[
"pymdownx.tasklist",
"tables",
"fenced_code",
"codehilite",
"mdx_math",
"pymdownx.tilde",
],
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
)
final_css_content = ""
if custom_css_path and custom_css_path.exists():
logger.debug(f"正在为 Markdown 渲染加载自定义样式: {custom_css_path}")
async with aiofiles.open(custom_css_path, encoding="utf-8") as f:
final_css_content = await f.read()
else:
css_paths = [
theme.default_assets_dir / "css/markdown/github-light.css",
theme.default_assets_dir / "css/markdown/pygments-default.css",
]
css_contents = []
for path in css_paths:
if path.exists():
async with aiofiles.open(path, encoding="utf-8") as f:
css_contents.append(await f.read())
final_css_content = "\n".join(css_contents)
template_context = {
"data": data,
"theme_css": theme.style_css,
"custom_style_css": final_css_content,
"md_html": md_html,
"extra_css": "",
"frameless": frameless,
"theme": {"name": theme.name},
}
template = jinja_env.get_template(template_name)
return await template.render_async(**template_context)
+2 -4
View File
@@ -11,7 +11,8 @@ class Theme(BaseModel):
name: str = Field(..., description="主题名称")
palette: dict[str, Any] = Field(
default_factory=dict, description="用于PIL渲染的调色板"
default_factory=dict,
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
)
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
assets_dir: Path = Field(..., description="主题的资产目录路径")
@@ -32,9 +33,6 @@ class TemplateManifest(BaseModel):
entrypoint: str = Field(
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
)
schema_path: str | None = Field(
None, description="用于数据验证的Pydantic模型的Python导入路径"
)
render_options: dict[str, Any] = Field(
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
)
+73
View File
@@ -0,0 +1,73 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable
from pathlib import Path
from typing import Any, Protocol
from pydantic import BaseModel
class Renderable(ABC):
"""
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
"""
@property
@abstractmethod
def template_name(self) -> str:
"""组件声明它需要哪个模板文件。"""
...
async def prepare(self) -> None:
"""
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
"""
pass
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于assets目录)。"""
return []
@abstractmethod
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""
返回一个将传递给模板的数据字典。
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
"""
...
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
"""
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
可以返回 str 或 awaitable[str]。
"""
return ""
class ScreenshotEngine(Protocol):
"""
一个协议,定义了截图引擎的核心能力。
"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
"""
将HTML字符串截图为图片。
参数:
html: 要渲染的HTML内容。
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
"""
...
class RenderResult(BaseModel):
"""
渲染服务的统一返回类型。
"""
image_bytes: bytes | None = None
html_content: str | None = None
+220 -386
View File
@@ -1,69 +1,58 @@
import asyncio
from collections.abc import Callable, Generator
from collections.abc import Callable
import hashlib
import json
from pathlib import Path
from typing import ClassVar, Literal
import aiofiles
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
import markdown
from pydantic import BaseModel, ValidationError
from jinja2 import (
Environment,
FileSystemLoader,
select_autoescape,
)
from nonebot.utils import is_coroutine_callable
import ujson as json
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
from zhenxun.services.log import logger
from zhenxun.utils.exception import RenderingError
from .engines import BaseEngine, HtmlRenderer, MarkdownEngine
from .models import TemplateManifest, Theme
THEME_PATH = THEMES_PATH
from .engine import get_screenshot_engine
from .protocols import Renderable, RenderResult, ScreenshotEngine
from .theme import ThemeManager
class RendererService:
"""图片渲染服务管理器。"""
"""
图片渲染服务的统一门面。
负责编排和调用底层渲染服务,提供统一的渲染接口。
支持多种渲染方式:组件渲染、模板渲染等。
"""
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
def __init__(self):
self._engines: dict[str, BaseEngine] = {
"html": HtmlRenderer(),
"markdown": MarkdownEngine(),
}
self._templates: dict[str, TemplateManifest] = {}
self._template_paths: dict[str, Path] = {}
self._plugin_template_paths: dict[str, Path] = {}
self._plugin_manifests: dict[str, TemplateManifest] = {}
self._init_lock = asyncio.Lock()
self._theme_manager: ThemeManager | None = None
self._screenshot_engine: ScreenshotEngine | None = None
self._initialized = False
self._current_theme_data: Theme | None = None
self._jinja_environments: dict[str, Environment] = {}
self._init_lock = asyncio.Lock()
self._custom_filters: dict[str, Callable] = {}
self._custom_globals: dict[str, Callable] = {}
self._markdown_styles: dict[str, Path] = {}
def register_template_namespace(self, namespace: str, path: Path):
"""
为插件注册一个模板命名空间。
参数:
namespace: 插件的唯一命名空间 (建议使用插件模块名)。
path: 包含模板文件的目录路径。
"""
"""[新增] 插件注册模板路径的入口点"""
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
logger.debug(f"已注册模板命名空间 '{namespace}' -> '{path}'")
def register_markdown_style(self, name: str, path: Path):
"""
[新增] 为 Markdown 渲染器注册一个具名样式。
参数:
name: 样式的唯一名称 (建议使用 '插件名:样式名' 格式以避免冲突)。
path: 指向 CSS 文件的 Path 对象。
为 Markdown 渲染器注册一个具名样式。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
@@ -75,10 +64,6 @@ class RendererService:
def filter(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 过滤器。
参数:
name: 过滤器在模板中的调用名称。为避免冲突,强烈建议使用
'插件名_过滤器名' 的格式。
"""
def decorator(func: Callable) -> Callable:
@@ -93,10 +78,6 @@ class RendererService:
def global_function(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 全局函数。
参数:
name: 函数在模板中的调用名称。为避免冲突,强烈建议使用
'插件名_函数名' 的格式。
"""
def decorator(func: Callable) -> Callable:
@@ -108,382 +89,235 @@ class RendererService:
return decorator
async def _load_theme(self, theme_name: str):
"""加载指定主题的配置和样式。"""
theme_dir = THEME_PATH / theme_name
if not theme_dir.is_dir():
logger.error(f"主题 '{theme_name}' 不存在,将回退到默认主题。")
if theme_name == "default":
return
theme_name = "default"
theme_dir = THEME_PATH / "default"
palette_path = theme_dir / "palette.json"
default_palette_path = THEMES_PATH / "default" / "palette.json"
palette = {}
if palette_path.exists():
try:
palette = json.loads(palette_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
logger.warning(f"主题 '{theme_name}' 的 palette.json 文件解析失败。")
if not palette and default_palette_path.exists():
logger.debug(
f"主题 '{theme_name}' 未提供有效的 palette.json,"
"回退到默认主题的调色板。"
)
try:
palette = json.loads(default_palette_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
logger.error("默认主题的 palette.json 文件解析失败,调色板将为空。")
palette = {}
elif not palette:
logger.error("当前主题和默认主题均未找到有效的 palette.json。")
self._current_theme_data = Theme(
name=theme_name,
palette=palette,
style_css="",
assets_dir=theme_dir / "assets",
default_assets_dir=THEMES_PATH / "default" / "assets",
)
self._jinja_environments.clear()
logger.info(f"渲染服务已加载主题: {theme_name}")
async def reload_theme(self) -> str:
"""
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
"""
async with self._init_lock:
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._load_theme(current_theme_name)
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
return current_theme_name
def _get_or_create_jinja_env(self, theme: Theme) -> Environment:
"""为指定主题获取或创建一个缓存的 Jinja2 环境。"""
if theme.name in self._jinja_environments:
return self._jinja_environments[theme.name]
logger.debug(f"为主题 '{theme.name}' 创建新的 Jinja2 环境...")
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
current_theme_templates_dir = THEMES_PATH / theme.name / "templates"
default_theme_templates_dir = THEMES_PATH / "default" / "templates"
theme_loader = FileSystemLoader(
[
str(current_theme_templates_dir.absolute()),
str(default_theme_templates_dir.absolute()),
]
)
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = Environment(
loader=final_loader,
enable_async=True,
autoescape=True,
)
def markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
if not isinstance(text, str):
return ""
return markdown.markdown(
text,
extensions=[
"pymdownx.tasklist",
"tables",
"fenced_code",
"codehilite",
"mdx_math",
"pymdownx.tilde",
],
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
)
env.filters["md"] = markdown_filter
if self._custom_filters:
env.filters.update(self._custom_filters)
logger.debug(
f"向 Jinja2 环境注入了 {len(self._custom_filters)} 个自定义过滤器。"
)
if self._custom_globals:
env.globals.update(self._custom_globals)
logger.debug(
f"向 Jinja2 环境注入了 {len(self._custom_globals)} 个自定义全局函数。"
)
self._jinja_environments[theme.name] = env
return env
async def initialize(self):
"""扫描并加载所有模板清单。"""
"""[新增] 延迟初始化方法,在 on_startup 钩子中调用"""
if self._initialized:
return
async with self._init_lock:
if self._initialized:
return
logger.info("开始扫描渲染模板...")
base_template_path = THEMES_PATH / "default" / "templates"
base_template_path.mkdir(exist_ok=True, parents=True)
for manifest_path in base_template_path.glob("**/manifest.json"):
template_dir = manifest_path.parent
try:
manifest = TemplateManifest.parse_file(manifest_path)
template_name = template_dir.relative_to(
base_template_path
).as_posix()
self._templates[template_name] = manifest
self._template_paths[template_name] = template_dir
logger.debug(
f"发现并加载基础模板 '{template_name}' "
f"(引擎: {manifest.engine})"
)
except ValidationError as e:
logger.error(f"解析模板清单 '{manifest_path}' 失败: {e}")
for namespace, plugin_template_path in self._plugin_template_paths.items():
for manifest_path in plugin_template_path.glob("**/manifest.json"):
template_dir = manifest_path.parent
try:
manifest = TemplateManifest.parse_file(manifest_path)
relative_path = template_dir.relative_to(
plugin_template_path
).as_posix()
template_name_with_ns = f"{namespace}:{relative_path}"
self._plugin_manifests[template_name_with_ns] = manifest
logger.debug(
f"发现并加载插件模板 '{template_name_with_ns}' "
f"(引擎: {manifest.engine})"
)
except ValidationError as e:
logger.error(f"解析插件模板清单 '{manifest_path}' 失败: {e}")
self._screenshot_engine = get_screenshot_engine()
self._theme_manager = ThemeManager(
self._plugin_template_paths,
self._custom_filters,
self._custom_globals,
self._markdown_styles,
)
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._load_theme(current_theme_name)
await self._theme_manager.load_theme(current_theme_name)
self._initialized = True
logger.info(
f"渲染模板扫描完成,共加载 {len(self._templates)} 个基础模板和 "
f"{len(self._plugin_manifests)} 个插件模板。"
)
def _yield_theme_paths(self, relative_path: Path) -> Generator[Path, None, None]:
async def _render_component(
self, component: Renderable, use_cache: bool = False, **render_options
) -> RenderResult:
"""
按优先级生成一个资源的完整路径(当前主题 -> 默认主题)。
核心的私有渲染方法,执行完整的渲染流程。
"""
if not self._current_theme_data:
return
cache_path = None
if Config.get_config("UI", "CACHE") and use_cache:
try:
template_name = component.template_name
data_dict = component.get_render_data()
current_theme_path = THEMES_PATH / self._current_theme_data.name / relative_path
yield current_theme_path
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
if self._current_theme_data.name != "default":
default_theme_path = THEMES_PATH / "default" / relative_path
yield default_theme_path
data_str = json.dumps(resolved_data_dict, sort_keys=True)
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
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
conventional_relative_paths = [
Path("templates")
/ "components"
/ "cards"
/ "markdown_image"
/ "styles"
/ f"{style_name}.css",
Path("assets") / "css" / "markdown" / f"{style_name}.css",
]
for relative_path in conventional_relative_paths:
for potential_path in self._yield_theme_paths(relative_path):
if potential_path.exists():
logger.debug(f"在约定路径找到 Markdown 样式: {potential_path}")
return potential_path
logger.warning(f"样式 '{style_name}' 在注册表和约定路径中均未找到。")
return None
def _resolve_style_path(self, template_name: str, style_name: str) -> Path | None:
"""
[重构后] 实现 当前主题 -> 默认主题 的回退查找逻辑
"""
relative_style_path = (
Path("templates") / template_name / "styles" / f"{style_name}.css"
)
for potential_path in self._yield_theme_paths(relative_style_path):
if potential_path.exists():
logger.debug(f"找到样式 '{style_name}': {potential_path}")
return potential_path
logger.warning(f"样式 '{style_name}' 在当前主题和默认主题中均未找到。")
return None
async def render(
self,
template_name: str,
data: dict | BaseModel | None = None,
use_cache: bool = False,
style_name: str | None = None,
**render_options_override,
) -> bytes:
"""
渲染指定的模板,并支持透明缓存。
"""
await self.initialize()
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
try:
extra_css_paths = []
custom_markdown_css_path = None
manifest: TemplateManifest | None = self._templates.get(
template_name
) or self._plugin_manifests.get(template_name)
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if style_name:
if manifest and manifest.engine == "markdown":
custom_markdown_css_path = self._resolve_markdown_style_path(
style_name
)
else:
resolved_path = self._resolve_style_path(template_name, style_name)
if resolved_path:
extra_css_paths.append(resolved_path)
if hasattr(component, "prepare"):
await component.prepare()
cache_path = None
if Config.get_config("UI", "CACHE") and use_cache:
try:
if isinstance(data, BaseModel):
data_str = f"{data.__class__.__name__}:{data!s}"
else:
data_str = json.dumps(data or {}, 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
required_scripts = set(component.get_required_scripts())
required_styles = set(component.get_required_styles())
if cache_path.exists():
logger.debug(f"UI缓存命中: {cache_path}")
async with aiofiles.open(cache_path, "rb") as f:
return await f.read()
logger.debug(f"UI缓存未命中: {cache_key_str[:100]}...")
except Exception as e:
logger.warning(f"UI缓存读取失败: {e}", e=e)
cache_path = None
if hasattr(component, "required_scripts"):
required_scripts.update(getattr(component, "required_scripts"))
if hasattr(component, "required_styles"):
required_styles.update(getattr(component, "required_styles"))
if not self._current_theme_data:
raise RuntimeError("主题未被正确加载,无法进行渲染。")
data_dict = component.get_render_data()
manifest: TemplateManifest | None = None
final_template_dir: Path | None = None
relative_template_name: str = ""
is_plugin_template = ":" in template_name
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
if is_plugin_template:
namespace, path_part = template_name.split(":", 1)
manifest = self._plugin_manifests.get(template_name)
if namespace in self._plugin_template_paths:
plugin_base_path = self._plugin_template_paths[namespace]
final_template_dir = plugin_base_path / Path(path_part).parent
manifest_options = {}
if manifest := await self._theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
relative_template_name = template_name
if manifest:
logger.debug(f"使用插件模板: '{template_name}'")
if (
getattr(component, "_is_standalone_template", False)
and hasattr(component, "template_path")
and isinstance(
template_path := getattr(component, "template_path"), Path
)
and template_path.is_absolute()
):
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
temp_loader = FileSystemLoader(str(template_dir))
temp_env = Environment(
loader=temp_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals["theme"] = self._theme_manager.jinja_env.globals.get(
"theme", {}
)
temp_env.filters["md"] = self._theme_manager._markdown_filter
template = temp_env.get_template(template_path.name)
html_content = await template.render_async(data=data_dict)
final_render_options = component_render_options.copy()
final_render_options.update(render_options)
image_bytes = await self._screenshot_engine.render(
html=html_content,
base_url_path=template_dir,
**final_render_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
else:
theme_template_dir = (
THEMES_PATH
/ self._current_theme_data.name
/ "templates"
/ template_name
)
default_template_dir = (
THEMES_PATH / "default" / "templates" / template_name
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(render_options)
if not self._theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
html_content = await self._theme_manager._render_component_to_html(
component,
required_scripts=list(required_scripts),
required_styles=list(required_styles),
**final_render_options,
)
if (
theme_template_dir.is_dir()
and (theme_template_dir / "manifest.json").is_file()
):
final_template_dir = theme_template_dir
logger.debug(
f"使用主题 '{self._current_theme_data.name}' "
f"覆盖的模板: '{template_name}'"
)
elif (
default_template_dir.is_dir()
and (default_template_dir / "manifest.json").is_file()
):
final_template_dir = default_template_dir
logger.debug(f"使用基础(default)模板: '{template_name}'")
screenshot_options = final_render_options.copy()
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
if final_template_dir:
image_bytes = await self._screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
manifest = TemplateManifest.parse_file(
final_template_dir / "manifest.json"
)
relative_template_name = (
Path(template_name) / manifest.entrypoint
).as_posix()
except (ValidationError, FileNotFoundError) as e:
logger.error(f"无法加载模板 '{template_name}' 的清单文件: {e}")
manifest = None
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
if not manifest or not final_template_dir:
raise ValueError(f"模板 '{template_name}' 未找到或清单文件加载失败。")
engine_name = manifest.engine
engine = self._engines.get(engine_name)
if not engine:
raise ValueError(f"未找到名为 '{engine_name}' 的渲染引擎。")
jinja_environment = self._get_or_create_jinja_env(self._current_theme_data)
final_render_options = manifest.render_options.copy()
final_render_options.update(render_options_override)
image_bytes = await engine.render(
template_name=relative_template_name,
data=data,
theme=self._current_theme_data,
jinja_env=jinja_environment,
extra_css_paths=extra_css_paths,
custom_css_path=custom_markdown_css_path,
**final_render_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return image_bytes
return RenderResult(image_bytes=image_bytes, html_content=html_content)
except Exception as e:
logger.error(
f"渲染模板 '{template_name}' 时发生错误", "RendererService", e=e
f"渲染组件 '{component.__class__.__name__}' 时发生错误",
"RendererService",
e=e,
)
raise RenderingError(f"渲染模板 '{template_name}' 失败") from e
raise RenderingError(
f"渲染组件 '{component.__class__.__name__}' 失败"
) from e
async def render(
self,
component: Renderable,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**render_options,
) -> bytes:
"""
统一的、多态的渲染入口,直接返回图片字节。
参数:
component: 一个 Renderable 实例 (如 RenderableComponent) 或一个
模板路径字符串。
use_cache: (可选) 是否启用渲染缓存,默认为 False。
**render_options: 传递给底层渲染引擎的额外参数。
返回:
bytes: 渲染后的图片数据。
"""
result = await self._render_component(
component,
use_cache=use_cache,
**render_options,
)
if debug_mode == "log" and result.html_content:
logger.info(
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
f"{result.html_content}\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) -> str:
"""调试方法:只执行到HTML生成步骤。"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
return await self._theme_manager._render_component_to_html(component)
async def reload_theme(self) -> str:
"""
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
return current_theme_name
+267
View File
@@ -0,0 +1,267 @@
from collections.abc import Callable
import inspect
from pathlib import Path
from typing import Any
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
import markdown
from pydantic import BaseModel
import ujson as json
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.models import TemplateManifest
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.exception import RenderingError
from zhenxun.utils.pydantic_compat import model_dump
class Theme(BaseModel):
name: str
palette: dict[str, Any]
style_css: str = ""
assets_dir: Path
default_assets_dir: Path
class ThemeManager:
def __init__(
self,
plugin_template_paths: dict[str, Path],
custom_filters: dict[str, Callable],
custom_globals: dict[str, Callable],
markdown_styles: dict[str, Path],
):
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(
[
str(THEMES_PATH / "current_theme_placeholder" / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
final_loader = ChoiceLoader([prefix_loader, theme_loader])
self.jinja_env = Environment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
self.current_theme: Theme | None = None
self._custom_filters = custom_filters
self._custom_globals = custom_globals
self._markdown_styles = markdown_styles
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
self.jinja_env.filters["md"] = self._markdown_filter
@staticmethod
def _markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
if not isinstance(text, str):
return ""
return markdown.markdown(
text,
extensions=[
"pymdownx.tasklist",
"tables",
"fenced_code",
"codehilite",
"mdx_math",
"pymdownx.tilde",
],
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
)
async def load_theme(self, theme_name: str = "default"):
theme_dir = THEMES_PATH / theme_name
if not theme_dir.is_dir():
logger.error(f"主题 '{theme_name}' 不存在,将回退到默认主题。")
if theme_name == "default":
raise FileNotFoundError("默认主题 'default' 未找到!")
theme_name = "default"
theme_dir = THEMES_PATH / "default"
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
current_loaders = list(self.jinja_env.loader.loaders)
if len(current_loaders) > 1:
current_loaders[1] = FileSystemLoader(
[
str(theme_dir / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
self.jinja_env.loader = ChoiceLoader(current_loaders)
else:
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
palette_path = theme_dir / "palette.json"
palette = (
json.loads(palette_path.read_text("utf-8")) if palette_path.exists() else {}
)
self.current_theme = Theme(
name=theme_name,
palette=palette,
assets_dir=theme_dir / "assets",
default_assets_dir=THEMES_PATH / "default" / "assets",
)
theme_context_dict = {
"name": theme_name,
"palette": palette,
"assets_dir": theme_dir / "assets",
"default_assets_dir": THEMES_PATH / "default" / "assets",
}
self.jinja_env.globals["theme"] = theme_context_dict
logger.info(f"主题管理器已加载主题: {theme_name}")
async def _resolve_component_template(self, component_path: str) -> str:
"""
智能解析组件路径。
如果路径是目录,则查找 manifest.json 以获取入口点。
"""
if Path(component_path).suffix:
return component_path
manifest_path_str = f"{component_path}/manifest.json"
if not self.jinja_env.loader:
raise TemplateNotFound(
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
)
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
entrypoint = manifest_data.get("entrypoint")
if not entrypoint:
raise RenderingError(
f"组件 '{component_path}' 的 manifest.json 中缺少 "
f"'entrypoint' 键。"
)
return f"{component_path}/{entrypoint}"
except TemplateNotFound:
logger.debug(
f"未找到 '{manifest_path_str}',将回退到默认的 'main.html' 入口点。"
)
return f"{component_path}/main.html"
raise TemplateNotFound(f"无法为组件 '{component_path}' 找到模板入口点。")
async def get_template_manifest(
self, component_path: str
) -> TemplateManifest | None:
"""
查找并解析组件的 manifest.json 文件。
"""
manifest_path_str = f"{component_path}/manifest.json"
if not self.jinja_env.loader:
return None
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
return TemplateManifest(**manifest_data)
except TemplateNotFound:
return None
return None
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
return None
async def _render_component_to_html(
self,
component: Renderable,
required_scripts: list[str] | None = None,
required_styles: list[str] | None = None,
**kwargs,
) -> str:
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
if not self.current_theme:
await self.load_theme()
assert self.current_theme is not None, "主题加载失败"
data_dict = component.get_render_data()
custom_style_css = ""
if hasattr(component, "get_extra_css"):
css_result = component.get_extra_css(self)
if inspect.isawaitable(css_result):
custom_style_css = await css_result
else:
custom_style_css = css_result
def asset_loader(asset_path: str) -> str:
"""[新增] 用于在Jinja2模板中解析静态资源的辅助函数。"""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
theme_context_dict = model_dump(self.current_theme)
theme_context_dict["asset"] = asset_loader
resolved_template_name = await self._resolve_component_template(
str(component.template_name)
)
logger.debug(
f"正在渲染组件 '{component.template_name}' "
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
"RendererService",
)
if self._custom_filters:
self.jinja_env.filters.update(self._custom_filters)
if self._custom_globals:
self.jinja_env.globals.update(self._custom_globals)
template = self.jinja_env.get_template(resolved_template_name)
template_context = {
"data": data_dict,
"theme": theme_context_dict,
"theme_css": "",
"custom_style_css": custom_style_css,
"required_scripts": required_scripts or [],
"required_styles": required_styles or [],
}
template_context.update(kwargs)
return await template.render_async(**template_context)