mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-07 12:50:03 +08:00
✨ 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:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
d9e65057cf
commit
7472cabd48
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
渲染器服务的共享配置和常量
|
||||
"""
|
||||
|
||||
RESERVED_TEMPLATE_KEYS: set[str] = {
|
||||
"data",
|
||||
"theme",
|
||||
"theme_css",
|
||||
"extra_css",
|
||||
"required_scripts",
|
||||
"required_styles",
|
||||
"frameless",
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user