✨ feat!(ui): 重构图表组件架构,实现数据与样式分离 (#2035)

* ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离

🏗️ **架构重构**
- 移除charts.py中所有硬编码样式参数(grid、tooltip、legend等)
- 将样式配置迁移至主题层style.json文件
- 统一图表模板消费样式文件的能力

📊 **图表组件优化**
- bar_chart: 移除grid和坐标轴show参数
- pie_chart: 移除tooltip、legend样式和series视觉参数
- line_chart: 移除tooltip、grid和坐标轴配置
- radar_chart: 移除tooltip硬编码

🎨 **主题系统增强**
- 新增pie_chart、line_chart、radar_chart的style.json配置
- 更新bar_chart/style.json,添加grid、xAxis、yAxis样式
- 所有图表模板支持deepMerge样式合并逻辑

🔧 **Breaking Changes**
- 图表工厂函数不再接受样式参数
- 主题开发者现可通过style.json完全定制图表外观
- 提升组件可维护性和主题灵活性

* 📦️ build(pyinstaller): 引入 resources.spec 并更新 .gitignore 规则

* 🚨 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-28 09:20:15 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent d9e65057cf
commit 7472cabd48
61 changed files with 2272 additions and 648 deletions
+13
View File
@@ -0,0 +1,13 @@
"""
渲染器服务的共享配置和常量
"""
RESERVED_TEMPLATE_KEYS: set[str] = {
"data",
"theme",
"theme_css",
"extra_css",
"required_scripts",
"required_styles",
"frameless",
}
+4
View File
@@ -33,6 +33,10 @@ class TemplateManifest(BaseModel):
entrypoint: str = Field(
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
)
styles: list[str] | str | None = Field(
None,
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
)
render_options: dict[str, Any] = Field(
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
)
+44 -5
View File
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable
from collections.abc import Awaitable, Iterable
from pathlib import Path
from typing import Any, Protocol
@@ -9,26 +9,50 @@ from pydantic import BaseModel
class Renderable(ABC):
"""
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
该协议确保了所有UI组件都能被 `RendererService` 以统一的方式处理。
任何想要被渲染服务处理的UI数据模型都应直接或间接实现此协议。
"""
component_css: str | None
@property
@abstractmethod
def template_name(self) -> str:
"""组件声明它需要哪个模板文件。"""
"""
返回用于渲染此组件的Jinja2模板的路径。
这是一个抽象属性,所有子类都必须覆盖它。
返回:
str: 指向模板文件的相对路径,例如 'components/core/table'。
"""
...
async def prepare(self) -> None:
"""
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
此方法会在组件的数据被传递给模板之前调用。
适合用于执行数据库查询、网络请求等耗时操作,以准备最终的渲染数据。
"""
pass
@abstractmethod
def get_children(self) -> Iterable["Renderable"]:
"""
[新增] 返回一个包含所有直接子组件的可迭代对象。
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
非容器组件应返回一个空列表。
"""
...
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
return []
@abstractmethod
@@ -36,13 +60,22 @@ class Renderable(ABC):
"""
返回一个将传递给模板的数据字典。
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
返回:
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
"""
...
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
"""
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
可以返回 str 或 awaitable[str]。
参数:
context: 当前的渲染上下文对象,可用于访问主题管理器等。
返回:
str | Awaitable[str]: 注入到页面的额外CSS字符串。
"""
return ""
@@ -50,6 +83,8 @@ class Renderable(ABC):
class ScreenshotEngine(Protocol):
"""
一个协议,定义了截图引擎的核心能力。
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
而无需修改上层渲染服务的代码。
"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
@@ -60,6 +95,9 @@ class ScreenshotEngine(Protocol):
html: 要渲染的HTML内容。
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
返回:
bytes: 渲染后的图片字节数据。
"""
...
@@ -67,6 +105,7 @@ class ScreenshotEngine(Protocol):
class RenderResult(BaseModel):
"""
渲染服务的统一返回类型。
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
"""
image_bytes: bytes | None = None
+32
View File
@@ -0,0 +1,32 @@
# File: zhenxun/services/renderer/registry.py
from pathlib import Path
from typing import ClassVar
from zhenxun.services.log import logger
class AssetRegistry:
"""一个独立的、用于存储由插件动态注册的资源的单例服务。"""
_markdown_styles: ClassVar[dict[str, Path]] = {}
def register_markdown_style(self, name: str, path: Path):
"""
为 Markdown 渲染器注册一个具名样式。
参数:
name (str): 样式的唯一名称。
path (Path): 指向该样式的CSS文件路径。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
def resolve_markdown_style(self, name: str) -> Path | None:
"""解析已注册的 Markdown 样式。"""
return self._markdown_styles.get(name)
asset_registry = AssetRegistry()
+366 -93
View File
@@ -1,13 +1,18 @@
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import hashlib
import inspect
from pathlib import Path
from typing import ClassVar, Literal
from typing import Any, ClassVar
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
from nonebot.utils import is_coroutine_callable
@@ -17,33 +22,101 @@ 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 zhenxun.utils.pydantic_compat import _dump_pydantic_obj
from .config import RESERVED_TEMPLATE_KEYS
from .engine import get_screenshot_engine
from .protocols import Renderable, RenderResult, ScreenshotEngine
from .theme import ThemeManager
from .registry import asset_registry
from .theme import RelativePathEnvironment, ThemeManager
@dataclass
class RenderContext:
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
renderer: "RendererService"
theme_manager: ThemeManager
screenshot_engine: ScreenshotEngine
component: Renderable
use_cache: bool
render_options: dict[str, Any]
resolved_template_paths: dict[str, str] = field(default_factory=dict)
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
collected_asset_styles: set[str] = field(default_factory=set)
collected_scripts: set[str] = field(default_factory=set)
collected_inline_css: list[str] = field(default_factory=list)
processed_components: set[int] = field(default_factory=set)
class RendererService:
"""
图片渲染服务的统一门面。
负责编排和调用底层渲染服务,提供统一的渲染接口。
支持多种渲染方式:组件渲染、模板渲染等。
作为UI渲染的中心枢纽,负责编排和调用底层服务,提供统一的渲染接口。
主要职责包括:
- 管理和加载UI主题 (通过 ThemeManager)。
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML。
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片。
- 处理插件注册的模板、过滤器和全局函数。
- (可选) 管理渲染结果的缓存。
"""
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
def __init__(self):
self._jinja_env: Environment | None = None
self._theme_manager: ThemeManager | None = None
self._screenshot_engine: ScreenshotEngine | None = None
self._initialized = False
self._init_lock = asyncio.Lock()
self._custom_filters: dict[str, Callable] = {}
self._custom_globals: dict[str, Callable] = {}
self._markdown_styles: dict[str, Path] = {}
self.filter("dump_json")(self._pydantic_tojson_filter)
def _create_jinja_env(self) -> Environment:
"""
创建并配置 Jinja2 渲染环境。
构建一个完整的 Jinja2 环境,包含:
- PrefixLoader:用于插件模板的命名空间加载
- FileSystemLoader:用于主题模板的文件系统加载
- RelativePathEnvironment:支持模板间相对路径引用的自定义环境
返回:
Environment: 完全配置好的 Jinja2 环境实例,准备接收自定义过滤器和全局函数。
"""
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = RelativePathEnvironment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
return env
def register_template_namespace(self, namespace: str, path: Path):
"""[新增] 插件注册模板路径的入口点"""
"""
为插件注册一个Jinja2模板命名空间。
这允许插件在自己的目录中维护模板,并通过
`{% include '@namespace/template.html' %}` 的方式引用它们,
避免了与核心或其他插件的模板命名冲突。
参数:
namespace: 插件的唯一命名空间,例如插件名。
path: 包含该插件模板的目录路径。
"""
if namespace in self._plugin_template_paths:
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
if not path.is_dir():
@@ -52,18 +125,25 @@ class RendererService:
def register_markdown_style(self, name: str, path: Path):
"""
为 Markdown 渲染器注册一个具名样式。
为 Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)。
参数:
name (str): 样式的唯一名称,例如 'cyberpunk'。
path (Path): 指向该样式的CSS文件路径。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
if not path.is_file():
raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
asset_registry.register_markdown_style(name, path)
def filter(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 过滤器。
参数:
name: 过滤器在模板中的调用名称。
返回:
Callable: 用于装饰过滤器函数的装饰器。
"""
def decorator(func: Callable) -> Callable:
@@ -78,6 +158,12 @@ class RendererService:
def global_function(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 全局函数。
参数:
name: 函数在模板中的调用名称。
返回:
Callable: 用于装饰全局函数的装饰器。
"""
def decorator(func: Callable) -> Callable:
@@ -90,46 +176,143 @@ class RendererService:
return decorator
async def initialize(self):
"""[新增] 延迟初始化方法,在 on_startup 钩子中调用"""
"""
[新增] 延迟初始化方法,在 on_startup 钩子中调用。
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
使用锁来防止并发初始化。
"""
if self._initialized:
return
async with self._init_lock:
if self._initialized:
return
self._jinja_env = self._create_jinja_env()
self._jinja_env.filters.update(self._custom_filters)
self._jinja_env.globals.update(self._custom_globals)
self._screenshot_engine = get_screenshot_engine()
self._theme_manager = ThemeManager(
self._plugin_template_paths,
self._custom_filters,
self._custom_globals,
self._markdown_styles,
)
self._theme_manager = ThemeManager(self._jinja_env)
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
self._initialized = True
async def _collect_dependencies_recursive(
self, component: Renderable, context: "RenderContext"
):
"""
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
这是实现组件化样式和脚本管理的基础,确保即使是深层嵌套的组件
所需的资源也能被正确加载到最终的HTML页面中。
"""
component_id = id(component)
if component_id in context.processed_components:
return
context.processed_components.add(component_id)
component_path_base = str(component.template_name)
manifest = await context.theme_manager.get_template_manifest(
component_path_base
)
style_paths_to_load = []
if manifest and manifest.styles:
styles = (
[manifest.styles]
if isinstance(manifest.styles, str)
else manifest.styles
)
for style_path in styles:
full_style_path = str(Path(component_path_base) / style_path).replace(
"\\", "/"
)
style_paths_to_load.append(full_style_path)
else:
resolved_template_name = (
await context.theme_manager._resolve_component_template(
component, context
)
)
conventional_style_path = str(
Path(resolved_template_name).with_name("style.css")
).replace("\\", "/")
style_paths_to_load.append(conventional_style_path)
for css_template_path in style_paths_to_load:
try:
css_template = context.theme_manager.jinja_env.get_template(
css_template_path
)
theme_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {})
}
css_content = await css_template.render_async(**theme_context)
context.collected_inline_css.append(css_content)
except TemplateNotFound:
pass
context.collected_scripts.update(component.get_required_scripts())
context.collected_asset_styles.update(component.get_required_styles())
if hasattr(component, "get_extra_css"):
res = component.get_extra_css(context)
css_str = await res if inspect.isawaitable(res) else str(res)
if css_str:
context.collected_inline_css.append(css_str)
for child in component.get_children():
if child:
await self._collect_dependencies_recursive(child, context)
async def _render_component(
self, component: Renderable, use_cache: bool = False, **render_options
self,
context: "RenderContext",
) -> RenderResult:
"""
核心的私有渲染方法,执行完整的渲染流程。
执行步骤:
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
并尝试从文件系统中读取缓存图片。
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
异步数据加载。
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
收集所有需要的CSS文件、JS文件和内联CSS。
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
此步骤会处理独立模板和主题内模板两种情况。
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
"""
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
if Config.get_config("UI", "CACHE") and use_cache:
component = context.component
if Config.get_config("UI", "CACHE") and context.use_cache:
try:
template_name = 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"
@@ -148,43 +331,46 @@ class RendererService:
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 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
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
"""
纯粹的核心渲染逻辑,不包含任何缓存处理。
此方法负责从组件数据模型生成最终的图片字节和HTML。
"""
component = context.component
try:
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if hasattr(component, "prepare"):
await component.prepare()
required_scripts = set(component.get_required_scripts())
required_styles = set(component.get_required_styles())
if hasattr(component, "required_scripts"):
required_scripts.update(getattr(component, "required_scripts"))
if hasattr(component, "required_styles"):
required_styles.update(getattr(component, "required_styles"))
data_dict = component.get_render_data()
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
manifest_options = {}
if manifest := await self._theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
assert context.theme_manager is not None, "ThemeManager 未初始化"
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
getattr(component, "_is_standalone_template", False)
and hasattr(component, "template_path")
hasattr(component, "template_path")
and isinstance(
template_path := getattr(component, "template_path"), Path
template_path := getattr(component, "template_path"),
Path,
)
and template_path.is_absolute()
):
await component.prepare()
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
@@ -195,45 +381,69 @@ class RendererService:
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals["theme"] = self._theme_manager.jinja_env.globals.get(
"theme", {}
temp_env.globals.update(context.theme_manager.jinja_env.globals)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.filters["md"] = self._theme_manager._markdown_filter
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
html_content = await template.render_async(data=data_dict)
template_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {}),
"data": data_dict,
}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 "
f"'data.{key}' 访问。"
)
else:
template_context[key] = value
html_content = await template.render_async(**template_context)
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
final_render_options = component_render_options.copy()
final_render_options.update(render_options)
final_render_options.update(context.render_options)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.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:
await component.prepare()
await self._collect_dependencies_recursive(component, context)
data_dict = component.get_render_data()
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
manifest_options = {}
if manifest := await context.theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(render_options)
final_render_options.update(context.render_options)
if not self._theme_manager.current_theme:
if not context.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),
html_content = await context.theme_manager._render_component_to_html(
context,
**final_render_options,
)
@@ -241,20 +451,12 @@ class RendererService:
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.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:
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)
except Exception as e:
@@ -271,27 +473,37 @@ class RendererService:
self,
component: Renderable,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**render_options,
) -> bytes:
"""
统一的、多态的渲染入口,直接返回图片字节。
参数:
component: 一个 Renderable 实例 (如 RenderableComponent) 或一个
模板路径字符串。
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
use_cache: (可选) 是否启用渲染缓存,默认为 False。
**render_options: 传递给底层渲染引擎的额外参数。
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
返回:
bytes: 渲染后的图片数据。
bytes: 渲染后的PNG图片字节数据。
异常:
RenderingError: 当渲染流程中任何步骤失败时抛出。
"""
result = await self._render_component(
component,
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=use_cache,
**render_options,
render_options=render_options,
)
if debug_mode == "log" and result.html_content:
result = await self._render_component(context)
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
logger.info(
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
f"{result.html_content}\n"
@@ -301,17 +513,44 @@ class RendererService:
raise RenderingError("渲染成功但未能生成图片字节数据。")
return result.image_bytes
async def render_to_html(self, component: Renderable) -> str:
"""调试方法:只执行到HTML生成步骤。"""
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()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
return await self._theme_manager._render_component_to_html(component)
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=False,
render_options={"frameless": frameless},
)
await self._collect_dependencies_recursive(component, context)
return await self._theme_manager._render_component_to_html(
context, frameless=frameless
)
async def reload_theme(self) -> str:
"""
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
这在开发主题时非常有用,可以热重载主题更改。
返回:
str: 已成功加载的主题名称。
"""
if not self._initialized:
await self.initialize()
@@ -321,3 +560,37 @@ class RendererService:
await self._theme_manager.load_theme(current_theme_name)
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)
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)
+364 -119
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import Callable
import inspect
import os
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import aiofiles
from jinja2 import (
@@ -10,9 +12,10 @@ from jinja2 import (
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
pass_context,
)
import markdown
from markupsafe import Markup
from pydantic import BaseModel
import ujson as json
@@ -20,9 +23,30 @@ 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.services.renderer.registry import asset_registry
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from .service import RenderContext
from .config import RESERVED_TEMPLATE_KEYS
class RelativePathEnvironment(Environment):
"""
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
"""
def join_path(self, template: str, parent: str) -> str:
"""
如果模板路径以 './' 或 '../' 开头,则视为相对于父模板的路径进行解析。
否则,使用默认的解析行为。
"""
if template.startswith("./") or template.startswith("../"):
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
return path.replace(os.path.sep, "/")
return super().join_path(template, parent)
class Theme(BaseModel):
name: str
@@ -33,41 +57,185 @@ class Theme(BaseModel):
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])
def __init__(self, env: Environment):
"""
主题管理器,负责UI主题的加载、解析和模板渲染。
self.jinja_env = Environment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
主要职责:
- 加载和管理UI主题,包括 `palette.json` (调色板) 和 `theme.css.jinja`(主题样式)
- 配置和持有核心的 Jinja2 环境实例。
- 向 Jinja2 环境注入全局函数,如 `asset()` 和 `render()`,供模板使用。
- 实现`asset()`函数的资源解析逻辑,支持皮肤、组件、主题和默认主题之间的资源回退
- 封装将 `Renderable` 组件渲染为最终HTML的复杂逻辑。
"""
self.jinja_env = env
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["render"] = self._global_render_component
self.jinja_env.globals["asset"] = self._create_asset_loader()
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
self.jinja_env.filters["md"] = self._markdown_filter
def list_available_themes(self) -> list[str]:
"""扫描主题目录并返回所有可用的主题名称。"""
if not THEMES_PATH.is_dir():
return []
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
def _find_component_root(self, start_path: Path) -> Path:
"""
从给定的起始路径向上查找,直到找到包含 manifest.json 的目录。
这被认为是组件的根目录。如果找不到,则返回起始路径的目录。
"""
current_path = start_path.parent
for _ in range(len(current_path.parts)):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _create_asset_loader(
self, local_base_path: Path | None = None
) -> Callable[..., str]:
"""
创建并返回一个用于解析静态资源的闭包函数 (Jinja2中的 `asset()` 函数)。
该函数实现了强大的资源解析回退逻辑,查找顺序如下:
1. **相对路径 (`./`)**: 优先查找相对于当前模板的 `assets` 目录。
- 这支持组件皮肤 (`skins/`) 对其资源的覆盖。
2. **当前主题**: 在当前激活主题的 `assets` 目录中查找。
3. **默认主题**: 如果当前主题未找到,则回退到 `default` 主题的 `assets` 目录。
参数:
local_base_path: (可选) 当渲染独立模板时,提供模板所在的目录。
"""
@pass_context
def asset_loader(ctx, asset_path: str) -> str:
if asset_path.startswith("./"):
parent_template_name = ctx.environment.get_template(ctx.name).name
parent_template_abs_path = Path(
ctx.environment.loader.get_source(
ctx.environment, parent_template_name
)[1]
)
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
skin_asset_path = skin_dir / "assets" / asset_path[2:]
if skin_asset_path.exists():
logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'")
return skin_asset_path.absolute().as_uri()
logger.debug(
f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源"
)
component_root = self._find_component_root(parent_template_abs_path)
local_asset = component_root / "assets" / asset_path[2:]
if local_asset.exists():
logger.debug(f"找到组件公共资源: '{local_asset}'")
return local_asset.absolute().as_uri()
logger.warning(
f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。"
)
return ""
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.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader
def _create_standalone_asset_loader(
self, local_base_path: Path
) -> Callable[[str], str]:
"""
[新增] 为独立模板创建一个专用的、更简单的 asset loader。
"""
def asset_loader(asset_path: str) -> str:
if asset_path.startswith("./"):
local_file = local_base_path / "assets" / asset_path[2:]
if local_file.exists():
return local_file.absolute().as_uri()
logger.warning(
f"独立模板本地资源 '{asset_path}' 在 "
f"'{local_base_path / 'assets'}' 中未找到。"
)
return ""
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.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader
async def _global_render_component(self, component: Renderable | None) -> str:
"""
一个全局的Jinja2函数,用于在模板内部渲染子组件
它封装了查找模板、设置上下文和渲染的逻辑。
"""
if not component:
return ""
try:
class MockContext:
def __init__(self):
self.resolved_template_paths = {}
self.theme_manager = self
mock_context = MockContext()
template_path = await self._resolve_component_template(
component,
mock_context, # type: ignore
)
template = self.jinja_env.get_template(template_path)
template_context = {
"data": component,
"frameless": True,
}
render_data = component.get_render_data()
template_context.update(render_data)
return Markup(await template.render_async(**template_context))
except Exception as e:
logger.error(
f"在全局 render 函数中渲染组件 '{component.__class__.__name__}' 失败",
e=e,
)
return f"<!-- 组件渲染失败{component.__class__.__name__}: {e} -->"
@staticmethod
def _markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
@@ -95,18 +263,30 @@ class ThemeManager:
theme_name = "default"
theme_dir = THEMES_PATH / "default"
default_palette_path = THEMES_PATH / "default" / "palette.json"
default_palette = (
json.loads(default_palette_path.read_text("utf-8"))
if default_palette_path.exists()
else {}
)
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"),
]
if len(current_loaders) > 1 and isinstance(
current_loaders[0], PrefixLoader
):
prefix_loader = current_loaders[0]
new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader])
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.jinja_env.loader = ChoiceLoader(current_loaders)
else:
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
palette_path = theme_dir / "palette.json"
palette = (
@@ -126,42 +306,74 @@ class ThemeManager:
"default_assets_dir": THEMES_PATH / "default" / "assets",
}
self.jinja_env.globals["theme"] = theme_context_dict
self.jinja_env.globals["default_theme_palette"] = default_palette
logger.info(f"主题管理器已加载主题: {theme_name}")
async def _resolve_component_template(self, component_path: str) -> str:
async def _resolve_component_template(
self, component: Renderable, context: "RenderContext"
) -> str:
"""
智能解析组件路径。
如果路径是目录,则查找 manifest.json 以获取入口点。
智能解析组件模板的路径,支持简单组件和带皮肤(variant)的复杂组件。
查找顺序如下:
1. **带皮肤的组件**: 如果组件定义了 `variant`,则在
`components/{component_name}/skins/{variant_name}/` 目录下查找入口文件。
2. **标准组件**: 在组件的根目录 `components/{component_name}/` 下查找入口文件。
3. **兼容模式**: (作为最终回退)直接查找名为`components/{component_name}.html`
的文件
入口文件名默认为 `main.html`,但可以被组件目录下的 `manifest.json` 文件中的
`entrypoint` 字段覆盖。
"""
if Path(component_path).suffix:
return component_path
component_path_base = str(component.template_name)
manifest_path_str = f"{component_path}/manifest.json"
variant = getattr(component, "variant", None)
cache_key = f"{component_path_base}::{variant or 'default'}"
if cached_path := context.resolved_template_paths.get(cache_key):
logger.trace(f"模板路径缓存命中: '{cache_key}' -> '{cached_path}'")
return cached_path
if not self.jinja_env.loader:
raise TemplateNotFound(
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
if Path(component_path_base).suffix:
try:
self.jinja_env.get_template(component_path_base)
logger.debug(f"解析到直接模板路径: '{component_path_base}'")
return component_path_base
except TemplateNotFound as e:
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
raise e
entrypoint_filename = "main.html"
manifest = await self.get_template_manifest(component_path_base)
if manifest and manifest.entrypoint:
entrypoint_filename = manifest.entrypoint
potential_paths = []
if variant:
potential_paths.append(
f"{component_path_base}/skins/{variant}/{entrypoint_filename}"
)
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}' 找到模板入口点。")
potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
if entrypoint_filename == "main.html":
potential_paths.append(f"{component_path_base}.html")
for path in potential_paths:
try:
self.jinja_env.get_template(path)
logger.debug(f"解析到模板路径: '{path}'")
context.resolved_template_paths[cache_key] = path
return path
except TemplateNotFound:
continue
err_msg = (
f"无法为组件 '{component_path_base}' 找到任何可用的模板。"
f"检查路径: {potential_paths}"
)
logger.error(err_msg)
raise TemplateNotFound(err_msg)
async def get_template_manifest(
self, component_path: str
@@ -186,82 +398,115 @@ class ThemeManager:
return None
return None
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
async def resolve_markdown_style_path(
self, style_name: str, context: "RenderContext"
) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
[新逻辑] 使用传入的上下文进行缓存。
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
if cached_path := context.resolved_style_paths.get(style_name):
logger.trace(f"Markdown样式路径缓存命中: '{style_name}'")
return cached_path
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
return None
resolved_path: Path | None = None
if registered_path := asset_registry.resolve_markdown_style(style_name):
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
resolved_path = registered_path
elif self.current_theme:
theme_style_path = (
self.current_theme.assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if theme_style_path.exists():
logger.debug(
f"在主题 '{self.current_theme.name}' 中找到"
f"Markdown 样式: '{style_name}'"
)
resolved_path = theme_style_path
default_style_path = (
self.current_theme.default_assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if not resolved_path and default_style_path.exists():
logger.debug(f"在 'default' 主题中找到 Markdown 样式: '{style_name}'")
resolved_path = default_style_path
if resolved_path:
context.resolved_style_paths[style_name] = resolved_path
else:
logger.warning(
f"Markdown 样式 '{style_name}' 在注册表和主题目录中均未找到。"
)
return resolved_path
async def _render_component_to_html(
self,
component: Renderable,
required_scripts: list[str] | None = None,
required_styles: list[str] | None = None,
context: "RenderContext",
**kwargs,
) -> str:
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
if not self.current_theme:
await self.load_theme()
component = context.component
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
theme_css_template = self.jinja_env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=theme_context_dict
)
resolved_template_name = await self._resolve_component_template(
str(component.template_name)
component, context
)
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)
unpacked_data = {}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 'data.{key}' 访问。"
)
else:
unpacked_data[key] = value
template_context = {
"data": data_dict,
"data": component,
"theme": theme_context_dict,
"theme_css": "",
"custom_style_css": custom_style_css,
"required_scripts": required_scripts or [],
"required_styles": required_styles or [],
"frameless": kwargs.get("frameless", False),
}
template_context.update(unpacked_data)
template_context.update(kwargs)
return await template.render_async(**template_context)
html_fragment = await template.render_async(**template_context)
if not kwargs.get("frameless", False):
base_template = self.jinja_env.get_template("partials/_base.html")
page_context = {
"data": component,
"theme_css": theme_css_content,
"collected_inline_css": context.collected_inline_css,
"required_scripts": list(context.collected_scripts),
"collected_asset_styles": list(context.collected_asset_styles),
"body_content": html_fragment,
}
return await base_template.render_async(**page_context)
else:
return html_fragment