mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-08 21:30:01 +08:00
♻️ 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
检查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:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
11524bcb04
commit
6124e217d0
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user