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

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

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

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

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

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

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2025-08-18 23:08:22 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent 11524bcb04
commit 6124e217d0
43 changed files with 1334 additions and 928 deletions
+22 -2
View File
@@ -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"
+1 -1
View File
@@ -19,4 +19,4 @@ class Badge(RenderableComponent):
@property
def template_name(self) -> str:
return "components/widgets/badge/main.html"
return "components/widgets/badge"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+2
View File
@@ -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",
]
+77 -9
View File
@@ -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
+30 -5
View File
@@ -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
+32 -1
View File
@@ -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 ""
+13 -3
View File
@@ -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
+7 -1
View File
@@ -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"
+25
View File
@@ -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
+1 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"