mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-06 04:10:02 +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
+139
-39
@@ -1,40 +1,140 @@
|
||||
from . import builders, models
|
||||
from .builders import (
|
||||
InfoCardBuilder,
|
||||
LayoutBuilder,
|
||||
MarkdownBuilder,
|
||||
NotebookBuilder,
|
||||
PluginHelpPageBuilder,
|
||||
PluginMenuBuilder,
|
||||
TableBuilder,
|
||||
)
|
||||
from .models import (
|
||||
HelpCategory,
|
||||
HelpItem,
|
||||
InfoCardData,
|
||||
PluginHelpPageData,
|
||||
PluginMenuCategory,
|
||||
PluginMenuData,
|
||||
PluginMenuItem,
|
||||
RenderableComponent,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
__all__ = [
|
||||
"HelpCategory",
|
||||
"HelpItem",
|
||||
"InfoCardBuilder",
|
||||
"InfoCardData",
|
||||
"LayoutBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginHelpPageData",
|
||||
"PluginMenuBuilder",
|
||||
"PluginMenuCategory",
|
||||
"PluginMenuData",
|
||||
"PluginMenuItem",
|
||||
"RenderableComponent",
|
||||
"TableBuilder",
|
||||
"builders",
|
||||
"models",
|
||||
]
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
|
||||
from .builders.core.layout import LayoutBuilder
|
||||
from .models.core.base import RenderableComponent
|
||||
from .models.core.markdown import MarkdownData
|
||||
from .models.core.template import TemplateComponent
|
||||
|
||||
|
||||
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
"""
|
||||
创建一个基于独立模板文件的UI组件。
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
|
||||
return TemplateComponent(template_path=path, data=data)
|
||||
|
||||
|
||||
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
|
||||
"""
|
||||
创建一个基于Markdown内容的UI组件。
|
||||
"""
|
||||
if isinstance(style, Path):
|
||||
return MarkdownData(markdown=content, css_path=str(style.absolute()))
|
||||
return MarkdownData(markdown=content, style_name=style)
|
||||
|
||||
|
||||
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个垂直布局组件。
|
||||
"""
|
||||
builder = LayoutBuilder.column(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
|
||||
|
||||
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个水平布局组件。
|
||||
"""
|
||||
builder = LayoutBuilder.row(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
|
||||
|
||||
async def render(
|
||||
component_or_path: Renderable | str | Path,
|
||||
data: dict | None = None,
|
||||
*,
|
||||
use_cache: bool = False,
|
||||
debug_mode: Literal["none", "log"] = "none",
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的UI渲染入口。
|
||||
|
||||
用法:
|
||||
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
|
||||
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
component: Renderable
|
||||
if isinstance(component_or_path, str | Path):
|
||||
if data is None:
|
||||
raise ValueError("使用模板路径渲染时必须提供 'data' 参数。")
|
||||
component = TemplateComponent(template_path=component_or_path, data=data)
|
||||
else:
|
||||
component = component_or_path
|
||||
|
||||
return await renderer_service.render(
|
||||
component, use_cache=use_cache, debug_mode=debug_mode, **kwargs
|
||||
)
|
||||
|
||||
|
||||
async def render_template(
|
||||
path: str | Path, data: dict, use_cache: bool = False, **kwargs
|
||||
) -> bytes:
|
||||
"""
|
||||
渲染一个独立的Jinja2模板文件。
|
||||
|
||||
这是一个便捷函数,封装了 render() 函数的调用,提供更简洁的模板渲染接口。
|
||||
|
||||
参数:
|
||||
path: 模板文件路径,相对于主题模板目录。
|
||||
data: 传递给模板的数据字典。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**kwargs: 传递给渲染服务的额外参数。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
"""
|
||||
return await render(path, data, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
async def render_markdown(
|
||||
md: str, style: str | Path | None = "default", use_cache: bool = False, **kwargs
|
||||
) -> bytes:
|
||||
"""
|
||||
将Markdown字符串渲染为图片。
|
||||
|
||||
这是一个便捷函数,封装了 render() 函数的调用,专门用于渲染Markdown内容。
|
||||
|
||||
参数:
|
||||
md: 要渲染的Markdown内容字符串。
|
||||
style: (可选) 样式名称或自定义CSS文件路径,默认为 "default"。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**kwargs: 传递给渲染服务的额外参数。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
"""
|
||||
component: MarkdownData
|
||||
if isinstance(style, Path):
|
||||
component = MarkdownData(markdown=md, css_path=str(style.absolute()))
|
||||
else:
|
||||
component = MarkdownData(markdown=md, style_name=style)
|
||||
|
||||
return await render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
from zhenxun.services.renderer.protocols import RenderResult
|
||||
|
||||
|
||||
async def render_full_result(
|
||||
component: Renderable, use_cache: bool = False, **kwargs
|
||||
) -> RenderResult:
|
||||
"""
|
||||
渲染组件并返回包含图片和HTML的完整结果对象,用于调试和高级用途。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
return await renderer_service._render_component(
|
||||
component, use_cache=use_cache, **kwargs
|
||||
)
|
||||
|
||||
+29
-13
@@ -3,8 +3,6 @@ from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
|
||||
|
||||
|
||||
@@ -15,6 +13,12 @@ class BaseBuilder(Generic[T_DataModel]):
|
||||
self._data: T_DataModel = data_model
|
||||
self._style_name: str | None = None
|
||||
self._template_name = template_name
|
||||
self._inline_style: dict | None = None
|
||||
self._extra_css: str | None = None
|
||||
|
||||
@property
|
||||
def data(self) -> T_DataModel:
|
||||
return self._data
|
||||
|
||||
def with_style(self, style_name: str) -> Self:
|
||||
"""
|
||||
@@ -23,19 +27,31 @@ class BaseBuilder(Generic[T_DataModel]):
|
||||
self._style_name = style_name
|
||||
return self
|
||||
|
||||
async def build(self, use_cache: bool = False, **render_options) -> bytes:
|
||||
def with_inline_style(self, style: dict[str, str]) -> Self:
|
||||
"""
|
||||
通用的构建方法,将数据渲染为图片。
|
||||
为组件的根元素应用动态的内联样式。
|
||||
|
||||
参数:
|
||||
style: 一个CSS样式字典,例如 {"background-color":"#fff","font-size":"16px"}
|
||||
"""
|
||||
self._inline_style = style
|
||||
return self
|
||||
|
||||
def with_extra_css(self, css: str) -> Self:
|
||||
"""
|
||||
向页面注入一段自定义的CSS样式字符串。
|
||||
|
||||
参数:
|
||||
css: 包含CSS规则的字符串。
|
||||
"""
|
||||
self._extra_css = css
|
||||
return self
|
||||
|
||||
def build(self) -> T_DataModel:
|
||||
"""
|
||||
构建并返回配置好的数据模型。
|
||||
"""
|
||||
if self._style_name and hasattr(self._data, "style_name"):
|
||||
setattr(self._data, "style_name", self._style_name)
|
||||
|
||||
data_to_render = self._data
|
||||
|
||||
return await renderer_service.render(
|
||||
template_name=self._template_name,
|
||||
data=data_to_render,
|
||||
use_cache=use_cache,
|
||||
style_name=self._style_name,
|
||||
**render_options,
|
||||
)
|
||||
return self._data
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import base64
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.layout import LayoutData, LayoutItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
@@ -10,108 +10,100 @@ __all__ = ["LayoutBuilder"]
|
||||
|
||||
class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
"""
|
||||
一个用于将多个图片(bytes)组合成单张图片的链式构建器。
|
||||
采用混合模式,提供便捷的工厂方法和灵活的自定义模板能力。
|
||||
一个用于将多个UI组件组合成单张图片的链式构建器。
|
||||
它通过在单个渲染流程中动态包含子模板来实现高质量的输出。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(LayoutData(), template_name="")
|
||||
self._items: list[LayoutItem] = []
|
||||
self._options: dict[str, Any] = {}
|
||||
self._preset_template_name: str | None = None
|
||||
|
||||
@classmethod
|
||||
def column(cls, **options: Any) -> Self:
|
||||
"""
|
||||
工厂方法:创建一个垂直列布局的构建器。
|
||||
:param options: 传递给模板的选项,如 gap, padding, align_items 等。
|
||||
"""
|
||||
builder = cls()
|
||||
builder._preset_template_name = "layouts/column"
|
||||
builder._template_name = "layouts/column"
|
||||
builder._options.update(options)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def grid(cls, **options: Any) -> Self:
|
||||
"""
|
||||
工厂方法:创建一个网格布局的构建器。
|
||||
:param options: 传递给模板的选项,如 columns, gap, padding 等。
|
||||
"""
|
||||
def row(cls, **options: Any) -> Self:
|
||||
builder = cls()
|
||||
builder._preset_template_name = "layouts/grid"
|
||||
builder._template_name = "layouts/row"
|
||||
builder._options.update(options)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def vstack(cls, images: list[bytes], **options: Any) -> Self:
|
||||
"""
|
||||
工厂方法:创建一个垂直堆叠布局的构建器,并直接添加图片。
|
||||
def hstack(
|
||||
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||
) -> Self:
|
||||
builder = cls.row(**options)
|
||||
for component in components:
|
||||
builder.add_item(component)
|
||||
return builder
|
||||
|
||||
参数:
|
||||
images: 要垂直堆叠的图片字节流列表。
|
||||
options: 传递给模板的选项,如 gap, padding, align_items 等。
|
||||
"""
|
||||
@classmethod
|
||||
def vstack(
|
||||
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||
) -> Self:
|
||||
builder = cls.column(**options)
|
||||
for image_bytes in images:
|
||||
builder.add_item(image_bytes)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def hstack(cls, images: list[bytes], **options: Any) -> Self:
|
||||
"""
|
||||
工厂方法:创建一个水平堆叠布局的构建器,并直接添加图片。
|
||||
|
||||
参数:
|
||||
images: 要水平堆叠的图片字节流列表。
|
||||
options: 传递给模板的选项,如 gap, padding, align_items 等。
|
||||
"""
|
||||
builder = cls()
|
||||
builder._preset_template_name = "layouts/row"
|
||||
builder._options.update(options)
|
||||
for image_bytes in images:
|
||||
builder.add_item(image_bytes)
|
||||
for component in components:
|
||||
builder.add_item(component)
|
||||
return builder
|
||||
|
||||
def add_item(
|
||||
self, image_bytes: bytes, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
component: "BaseBuilder | RenderableComponent",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
向布局中添加一个图片项目。
|
||||
:param image_bytes: 图片的原始字节数据。
|
||||
:param metadata: (可选) 与此项目关联的元数据,可用于模板。
|
||||
向布局中添加一个组件,支持多种组件类型的添加。
|
||||
|
||||
参数:
|
||||
component: 一个 Builder 实例 (如 TableBuilder) 或一个 RenderableComponent
|
||||
数据模型。
|
||||
metadata: (可选) 与此项目关联的元数据,可用于模板。
|
||||
|
||||
返回:
|
||||
Self: 返回当前布局构建器实例,支持链式调用。
|
||||
"""
|
||||
b64_string = base64.b64encode(image_bytes).decode("utf-8")
|
||||
src = f"data:image/png;base64,{b64_string}"
|
||||
self._items.append(LayoutItem(src=src, metadata=metadata))
|
||||
component_data = (
|
||||
component.data if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
self._data.children.append(
|
||||
LayoutItem(component=component_data, metadata=metadata)
|
||||
)
|
||||
return self
|
||||
|
||||
def add_option(self, key: str, value: Any) -> Self:
|
||||
"""
|
||||
为布局添加一个自定义选项,该选项会传递给模板。
|
||||
|
||||
参数:
|
||||
key: 选项的键名,用于在模板中引用。
|
||||
value: 选项的值,可以是任意类型的数据。
|
||||
|
||||
返回:
|
||||
Self: 返回当前布局构建器实例,支持链式调用。
|
||||
"""
|
||||
self._options[key] = value
|
||||
return self
|
||||
|
||||
async def build(
|
||||
self, use_cache: bool = False, template: str | None = None, **render_options
|
||||
) -> bytes:
|
||||
def build(self) -> LayoutData:
|
||||
"""
|
||||
构建最终的布局图片。
|
||||
:param use_cache: 是否使用缓存。
|
||||
:param template: (可选) 强制使用指定的模板,覆盖工厂方法的预设。
|
||||
这是实现自定义布局的关键。
|
||||
:param render_options: 传递给渲染引擎的额外选项。
|
||||
"""
|
||||
final_template_name = template or self._preset_template_name
|
||||
[修改] 构建并返回 LayoutData 模型实例。
|
||||
此方法现在是同步的,并且不执行渲染。
|
||||
|
||||
if not final_template_name:
|
||||
参数:
|
||||
无
|
||||
|
||||
返回:
|
||||
LayoutData: 配置好的布局数据模型。
|
||||
"""
|
||||
if not self._template_name:
|
||||
raise ValueError(
|
||||
"必须通过工厂方法 (如 LayoutBuilder.column()) 或在 build() "
|
||||
"方法中提供一个模板名称。"
|
||||
"必须通过工厂方法 (如 LayoutBuilder.column()) 初始化布局类型。"
|
||||
)
|
||||
|
||||
self._data.items = self._items
|
||||
self._data.options = self._options
|
||||
self._template_name = final_template_name
|
||||
|
||||
return await super().build(use_cache=use_cache, **render_options)
|
||||
self._data.layout_type = self._template_name.split("/")[-1]
|
||||
return self._data
|
||||
|
||||
@@ -140,10 +140,12 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
|
||||
self._append_element(RawHtmlElement(html="---"))
|
||||
return self
|
||||
|
||||
async def build(self, use_cache: bool = False, **render_options) -> bytes:
|
||||
"""构建Markdown图片"""
|
||||
def build(self) -> MarkdownData:
|
||||
"""
|
||||
构建并返回 MarkdownData 模型实例。
|
||||
"""
|
||||
final_markdown = "\n\n".join(part.to_markdown() for part in self._parts).strip()
|
||||
self._data.markdown = final_markdown
|
||||
self._data.width = self._width
|
||||
self._data.css_path = self._css_path
|
||||
return await super().build(use_cache=use_cache, **render_options)
|
||||
return super().build()
|
||||
|
||||
@@ -78,10 +78,25 @@ class NotebookBuilder(BaseBuilder[NotebookData]):
|
||||
self.add_component(Divider(**kwargs))
|
||||
return self
|
||||
|
||||
def add_component(self, component: RenderableComponent) -> "NotebookBuilder":
|
||||
"""向 Notebook 中添加一个可渲染的自定义组件。"""
|
||||
def add_component(
|
||||
self, component: "RenderableComponent | BaseBuilder"
|
||||
) -> "NotebookBuilder":
|
||||
"""
|
||||
向 Notebook 中添加一个可渲染的自定义组件。
|
||||
|
||||
"""
|
||||
component_data = (
|
||||
component.data if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
|
||||
if not isinstance(component_data, RenderableComponent):
|
||||
raise TypeError(
|
||||
f"add_component 只能接受 RenderableComponent 或其 Builder,"
|
||||
f"但收到了 {type(component)}"
|
||||
)
|
||||
|
||||
self._elements.append(
|
||||
NotebookElement(type="component", component_data=component)
|
||||
NotebookElement(type="component", component=component_data)
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -97,11 +112,9 @@ class NotebookBuilder(BaseBuilder[NotebookData]):
|
||||
self.quote(quote)
|
||||
return self
|
||||
|
||||
async def build(
|
||||
self, use_cache: bool = False, frameless: bool = False, **render_options
|
||||
) -> bytes:
|
||||
"""构建Notebook图片"""
|
||||
def build(self) -> NotebookData:
|
||||
"""
|
||||
构建并返回 NotebookData 模型实例。
|
||||
"""
|
||||
self._data.elements = self._elements
|
||||
return await super().build(
|
||||
use_cache=use_cache, frameless=frameless, **render_options
|
||||
)
|
||||
return super().build()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from typing import Literal
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .core.base import RenderableComponent
|
||||
|
||||
|
||||
class BaseChartData(BaseModel):
|
||||
class BaseChartData(RenderableComponent):
|
||||
"""所有图表数据模型的基类"""
|
||||
|
||||
style_name: str | None = None
|
||||
title: str
|
||||
chart_id: str = Field(default_factory=lambda: f"chart-{uuid.uuid4().hex}")
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""声明此组件需要 ECharts 库。"""
|
||||
return ["js/echarts.min.js"]
|
||||
|
||||
|
||||
class BarChartData(BaseChartData):
|
||||
@@ -18,6 +26,10 @@ class BarChartData(BaseChartData):
|
||||
direction: Literal["horizontal", "vertical"] = "horizontal"
|
||||
background_image: str | None = None
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/bar_chart"
|
||||
|
||||
|
||||
class PieChartDataItem(BaseModel):
|
||||
name: str
|
||||
@@ -29,6 +41,10 @@ class PieChartData(BaseChartData):
|
||||
|
||||
data: list[PieChartDataItem]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/pie_chart"
|
||||
|
||||
|
||||
class LineChartSeries(BaseModel):
|
||||
name: str
|
||||
@@ -41,3 +57,7 @@ class LineChartData(BaseChartData):
|
||||
|
||||
category_data: list[str]
|
||||
series: list[LineChartSeries]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/line_chart"
|
||||
|
||||
@@ -19,4 +19,4 @@ class Badge(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/badge/main.html"
|
||||
return "components/widgets/badge"
|
||||
|
||||
@@ -18,7 +18,7 @@ class Divider(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/divider/main.html"
|
||||
return "components/widgets/divider"
|
||||
|
||||
|
||||
class Rectangle(RenderableComponent):
|
||||
@@ -32,4 +32,4 @@ class Rectangle(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/rectangle/main.html"
|
||||
return "components/widgets/rectangle"
|
||||
|
||||
@@ -21,4 +21,4 @@ class ProgressBar(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/progress_bar/main.html"
|
||||
return "components/widgets/progress_bar"
|
||||
|
||||
@@ -20,4 +20,4 @@ class UserInfoBlock(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/user_info_block/main.html"
|
||||
return "components/widgets/user_info_block"
|
||||
|
||||
@@ -20,6 +20,7 @@ from .markdown import (
|
||||
)
|
||||
from .notebook import NotebookData, NotebookElement
|
||||
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell
|
||||
from .template import TemplateComponent
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"TableCell",
|
||||
"TableData",
|
||||
"TableElement",
|
||||
"TemplateComponent",
|
||||
"TextCell",
|
||||
"TextElement",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,88 @@
|
||||
"""
|
||||
核心基础模型定义
|
||||
用于存放 RenderableComponent 基类
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Iterator
|
||||
from typing import Any
|
||||
|
||||
from nonebot.compat import model_dump
|
||||
from pydantic import BaseModel
|
||||
|
||||
__all__ = ["RenderableComponent"]
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
|
||||
__all__ = ["ContainerComponent", "RenderableComponent"]
|
||||
|
||||
|
||||
class RenderableComponent(BaseModel, ABC):
|
||||
class RenderableComponent(BaseModel, Renderable):
|
||||
"""所有可渲染UI组件的抽象基类。"""
|
||||
|
||||
_is_standalone_template: bool = False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def template_name(self) -> str:
|
||||
"""返回用于渲染此组件的Jinja2模板的路径。"""
|
||||
"""
|
||||
返回用于渲染此组件的Jinja2模板的路径。
|
||||
这是一个抽象属性,所有子类都必须覆盖它。
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement the 'template_name' property."
|
||||
)
|
||||
|
||||
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 []
|
||||
|
||||
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
|
||||
"""默认实现,返回模型自身的数据字典。"""
|
||||
return model_dump(self)
|
||||
|
||||
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
|
||||
return ""
|
||||
|
||||
|
||||
class ContainerComponent(RenderableComponent, ABC):
|
||||
"""
|
||||
一个为容器类组件设计的抽象基类,封装了预渲染子组件的通用逻辑。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def _get_renderable_child_items(self) -> Iterator[Any]:
|
||||
"""
|
||||
一个抽象方法,子类必须实现它来返回一个可迭代的对象。
|
||||
迭代器中的每个项目都必须具有 'component' 和 'html_content' 属性。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""
|
||||
通用的 prepare 方法,负责预渲染所有子组件。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
child_items = list(self._get_renderable_child_items())
|
||||
if not child_items:
|
||||
return
|
||||
|
||||
components_to_render = [
|
||||
item.component for item in child_items if item.component
|
||||
]
|
||||
|
||||
prepare_tasks = [
|
||||
comp.prepare() for comp in components_to_render if hasattr(comp, "prepare")
|
||||
]
|
||||
if prepare_tasks:
|
||||
await asyncio.gather(*prepare_tasks)
|
||||
|
||||
render_tasks = [
|
||||
renderer_service.render_to_html(comp) for comp in components_to_render
|
||||
]
|
||||
rendered_htmls = await asyncio.gather(*render_tasks)
|
||||
|
||||
for item, html in zip(child_items, rendered_htmls):
|
||||
item.html_content = html
|
||||
|
||||
@@ -2,23 +2,48 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = ["LayoutData", "LayoutItem"]
|
||||
|
||||
|
||||
class LayoutItem(BaseModel):
|
||||
"""布局中的单个项目,通常是一张图片"""
|
||||
"""布局中的单个项目,现在持有可渲染组件的数据模型"""
|
||||
|
||||
src: str = Field(..., description="图片的Base64数据URI")
|
||||
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
|
||||
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
|
||||
html_content: str | None = None
|
||||
|
||||
|
||||
class LayoutData(BaseModel):
|
||||
class LayoutData(ContainerComponent):
|
||||
"""布局构建器的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
items: list[LayoutItem] = Field(
|
||||
layout_type: str = "column"
|
||||
children: list[LayoutItem] = Field(
|
||||
default_factory=list, description="要布局的项目列表"
|
||||
)
|
||||
options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给模板的布局选项"
|
||||
default_factory=dict, description="传递给模板的选项"
|
||||
)
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的脚本依赖。"""
|
||||
scripts = set()
|
||||
for item in self.children:
|
||||
scripts.update(item.component.get_required_scripts())
|
||||
return list(scripts)
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的样式依赖。"""
|
||||
styles = set()
|
||||
for item in self.children:
|
||||
styles.update(item.component.get_required_styles())
|
||||
return list(styles)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return f"layouts/{self.layout_type}"
|
||||
|
||||
def _get_renderable_child_items(self):
|
||||
yield from self.children
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import aiofiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"CodeElement",
|
||||
"HeadingElement",
|
||||
@@ -115,10 +121,35 @@ class ListElement(ContainerElement):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MarkdownData(BaseModel):
|
||||
class MarkdownData(RenderableComponent):
|
||||
"""Markdown转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
markdown: str
|
||||
width: int = 800
|
||||
css_path: str | None = None
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/markdown"
|
||||
|
||||
async def get_extra_css(self, theme_manager) -> str:
|
||||
if self.css_path:
|
||||
css_file = Path(self.css_path)
|
||||
if css_file.is_file():
|
||||
async with aiofiles.open(css_file, encoding="utf-8") as f:
|
||||
return await f.read()
|
||||
else:
|
||||
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
|
||||
else:
|
||||
style_name = self.style_name or "github-light"
|
||||
css_path = (
|
||||
theme_manager.current_theme.default_assets_dir
|
||||
/ "css"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
)
|
||||
if css_path.exists():
|
||||
async with aiofiles.open(css_path, encoding="utf-8") as f:
|
||||
return await f.read()
|
||||
return ""
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .base import RenderableComponent
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = ["NotebookData", "NotebookElement"]
|
||||
|
||||
@@ -28,11 +28,21 @@ class NotebookElement(BaseModel):
|
||||
language: str | None = None
|
||||
data: list[str] | None = None
|
||||
ordered: bool | None = None
|
||||
component_data: RenderableComponent | None = None
|
||||
component: RenderableComponent | None = None
|
||||
html_content: str | None = None
|
||||
|
||||
|
||||
class NotebookData(BaseModel):
|
||||
class NotebookData(ContainerComponent):
|
||||
"""Notebook转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
elements: list[NotebookElement]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/notebook"
|
||||
|
||||
def _get_renderable_child_items(self):
|
||||
for element in self.elements:
|
||||
if element.type == "component" and element.component:
|
||||
yield element
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"ImageCell",
|
||||
@@ -49,7 +51,7 @@ class StatusBadgeCell(BaseCell):
|
||||
TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None
|
||||
|
||||
|
||||
class TableData(BaseModel):
|
||||
class TableData(RenderableComponent):
|
||||
"""通用表格的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
@@ -57,3 +59,7 @@ class TableData(BaseModel):
|
||||
tip: str | None = Field(None, description="表格下方的提示信息")
|
||||
headers: list[str] = Field(default_factory=list, description="表头列表")
|
||||
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/table"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
__all__ = ["TemplateComponent"]
|
||||
|
||||
|
||||
class TemplateComponent(RenderableComponent):
|
||||
"""基于独立模板文件的UI组件"""
|
||||
|
||||
_is_standalone_template: bool = True
|
||||
template_path: str | Path
|
||||
data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
"""返回模板路径"""
|
||||
if isinstance(self.template_path, Path):
|
||||
return self.template_path.as_posix()
|
||||
return str(self.template_path)
|
||||
|
||||
def get_render_data(self) -> dict[str, Any]:
|
||||
"""返回传递给模板的数据"""
|
||||
return self.data
|
||||
@@ -33,5 +33,4 @@ class InfoCardData(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
"""返回用于渲染此组件的Jinja2模板的路径。"""
|
||||
return "components/presets/info_card/main.html"
|
||||
return "components/presets/info_card"
|
||||
|
||||
@@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "pages/core/help_page/main.html"
|
||||
return "pages/core/help_page"
|
||||
|
||||
@@ -39,4 +39,4 @@ class PluginMenuData(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "pages/core/plugin_menu/main.html"
|
||||
return "pages/core/plugin_menu"
|
||||
|
||||
Reference in New Issue
Block a user