✨ 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
+11
View File
@@ -4,7 +4,10 @@
"""
from .base import RenderableComponent
from .card import CardData
from .details import DetailsData, DetailsItem
from .layout import LayoutData, LayoutItem
from .list import ListData, ListItem
from .markdown import (
CodeElement,
HeadingElement,
@@ -21,16 +24,22 @@ from .markdown import (
from .notebook import NotebookData, NotebookElement
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell
from .template import TemplateComponent
from .text import TextData, TextSpan
__all__ = [
"BaseCell",
"CardData",
"CodeElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
"ImageCell",
"ImageElement",
"LayoutData",
"LayoutItem",
"ListData",
"ListElement",
"ListItem",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
@@ -45,5 +54,7 @@ __all__ = [
"TableElement",
"TemplateComponent",
"TextCell",
"TextData",
"TextElement",
"TextSpan",
]
+44 -35
View File
@@ -1,20 +1,29 @@
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Iterator
from collections.abc import Awaitable, Iterable
from typing import Any
from nonebot.compat import model_dump
from pydantic import BaseModel
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
__all__ = ["ContainerComponent", "RenderableComponent"]
class RenderableComponent(BaseModel, Renderable):
"""所有可渲染UI组件的抽象基类。"""
"""
所有可渲染UI组件的数据模型基类。
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
协议,确保其能够被 `RendererService` 正确处理。
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
"""
_is_standalone_template: bool = False
inline_style: dict[str, str] | None = None
component_css: str | None = None
extra_classes: list[str] | None = None
variant: str | None = None
@property
def template_name(self) -> str:
@@ -30,6 +39,10 @@ class RenderableComponent(BaseModel, Renderable):
"""[可选] 生命周期钩子,默认无操作。"""
pass
def get_children(self) -> Iterable["RenderableComponent"]:
"""默认实现:非容器组件没有子组件。"""
return []
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
return []
@@ -40,9 +53,18 @@ class RenderableComponent(BaseModel, Renderable):
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""默认实现,返回模型自身的数据字典。"""
return model_dump(self)
return model_dump(
self, exclude={"inline_style", "component_css", "inline_style_str"}
)
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
@compat_computed_field
def inline_style_str(self) -> str:
"""[新增] 一个辅助属性,将内联样式字典转换为CSS字符串"""
if not self.inline_style:
return ""
return "; ".join(f"{k}: {v}" for k, v in self.inline_style.items())
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
return ""
@@ -52,37 +74,24 @@ class ContainerComponent(RenderableComponent, ABC):
"""
@abstractmethod
def _get_renderable_child_items(self) -> Iterator[Any]:
def get_children(self) -> Iterable[RenderableComponent]:
"""
一个抽象方法,子类必须实现它来返回一个可迭代的对象。
迭代器中的每个项目都必须具有 'component' 和 'html_content' 属性。
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
"""
raise NotImplementedError
async def prepare(self) -> None:
"""
通用的 prepare 方法,负责预渲染所有子组件。
"""
from zhenxun.services import renderer_service
def get_required_scripts(self) -> list[str]:
"""[新增] 聚合所有子组件的脚本依赖。"""
scripts = set(super().get_required_scripts())
for child in self.get_children():
if child:
scripts.update(child.get_required_scripts())
return list(scripts)
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
def get_required_styles(self) -> list[str]:
"""[新增] 聚合所有子组件的样式依赖。"""
styles = set(super().get_required_styles())
for child in self.get_children():
if child:
styles.update(child.get_required_styles())
return list(styles)
+24
View File
@@ -0,0 +1,24 @@
from collections.abc import Iterable
from .base import ContainerComponent, RenderableComponent
class CardData(ContainerComponent):
"""通用卡片的数据模型,可以包含头部、内容和尾部"""
header: RenderableComponent | None = None
content: RenderableComponent
footer: RenderableComponent | None = None
@property
def template_name(self) -> str:
return "components/core/card"
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS收集器能够遍历卡片的子组件"""
if self.header:
yield self.header
if self.content:
yield self.content
if self.footer:
yield self.footer
+23
View File
@@ -0,0 +1,23 @@
from typing import Any
from pydantic import BaseModel, Field
from .base import RenderableComponent
class DetailsItem(BaseModel):
"""描述列表中的单个项目"""
label: str = Field(..., description="项目的标签/键")
value: Any = Field(..., description="项目的值")
class DetailsData(RenderableComponent):
"""描述列表(键值对)的数据模型"""
title: str | None = Field(None, description="列表的可选标题")
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
@property
def template_name(self) -> str:
return "components/core/details"
+21 -18
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field
@@ -12,7 +13,6 @@ class LayoutItem(BaseModel):
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
html_content: str | None = None
class LayoutData(ContainerComponent):
@@ -27,23 +27,26 @@ class LayoutData(ContainerComponent):
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}"
return f"components/core/layouts/{self.layout_type}"
def _get_renderable_child_items(self):
yield from self.children
def get_extra_css(self, context: Any) -> str:
"""聚合所有子组件的 extra_css。"""
all_css = []
if self.component_css:
all_css.append(self.component_css)
for item in self.children:
if (
item.component
and hasattr(item.component, "component_css")
and item.component.component_css
):
all_css.append(item.component.component_css)
return "\n".join(all_css)
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.children:
yield item.component
+30
View File
@@ -0,0 +1,30 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel, Field
from .base import ContainerComponent, RenderableComponent
__all__ = ["ListData", "ListItem"]
class ListItem(BaseModel):
"""列表中的单个项目,其内容可以是任何可渲染组件。"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
class ListData(ContainerComponent):
"""通用列表的数据模型,支持有序和无序列表。"""
component_type: Literal["list"] = "list"
items: list[ListItem] = Field(default_factory=list, description="列表项目")
ordered: bool = Field(default=False, description="是否为有序列表")
@property
def template_name(self) -> str:
return "components/core/list"
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.items:
yield item.component
+47 -12
View File
@@ -1,16 +1,18 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Literal
from typing import Any, Literal
import aiofiles
from pydantic import BaseModel, Field
from zhenxun.services.log import logger
from .base import RenderableComponent
from .base import ContainerComponent, RenderableComponent
__all__ = [
"CodeElement",
"ComponentElement",
"HeadingElement",
"ImageElement",
"ListElement",
@@ -32,6 +34,7 @@ class MarkdownElement(BaseModel, ABC):
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
text: str
def to_markdown(self) -> str:
@@ -39,6 +42,7 @@ class TextElement(MarkdownElement):
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
text: str
level: int = Field(..., ge=1, le=6)
@@ -47,6 +51,7 @@ class HeadingElement(MarkdownElement):
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
alt: str = "image"
@@ -55,6 +60,7 @@ class ImageElement(MarkdownElement):
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
language: str = ""
@@ -63,6 +69,7 @@ class CodeElement(MarkdownElement):
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
def to_markdown(self) -> str:
@@ -70,6 +77,7 @@ class RawHtmlElement(MarkdownElement):
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
rows: list[list[str]]
alignments: list[Literal["left", "center", "right"]] | None = None
@@ -98,6 +106,8 @@ class ContainerElement(MarkdownElement):
class QuoteElement(ContainerElement):
type: Literal["quote"] = "quote"
def to_markdown(self) -> str:
inner_md = "\n".join(part.to_markdown() for part in self.content)
return "\n".join([f"> {line}" for line in inner_md.split("\n")])
@@ -109,6 +119,7 @@ class ListItemElement(ContainerElement):
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
def to_markdown(self) -> str:
@@ -121,11 +132,21 @@ class ListElement(ContainerElement):
return "\n".join(lines)
class MarkdownData(RenderableComponent):
class ComponentElement(MarkdownElement):
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
type: Literal["component"] = "component"
component: RenderableComponent
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型"""
style_name: str | None = None
markdown: str
elements: list[MarkdownElement] = Field(default_factory=list)
width: int = 800
css_path: str | None = None
@@ -133,7 +154,23 @@ class MarkdownData(RenderableComponent):
def template_name(self) -> str:
return "components/core/markdown"
async def get_extra_css(self, theme_manager) -> str:
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS/JS依赖收集器能够递归地找到所有嵌入的组件。"""
def find_components_recursive(
elements: list[MarkdownElement],
) -> Iterable[RenderableComponent]:
for element in elements:
if isinstance(element, ComponentElement):
yield element.component
if hasattr(element.component, "get_children"):
yield from element.component.get_children()
elif isinstance(element, ContainerElement):
yield from find_components_recursive(element.content)
yield from find_components_recursive(self.elements)
async def get_extra_css(self, context: Any) -> str:
if self.css_path:
css_file = Path(self.css_path)
if css_file.is_file():
@@ -142,14 +179,12 @@ class MarkdownData(RenderableComponent):
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"
style_name = self.style_name or "light"
# 使用上下文对象来解析路径
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path.exists():
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
return await f.read()
return ""
+3 -3
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel
@@ -29,7 +30,6 @@ class NotebookElement(BaseModel):
data: list[str] | None = None
ordered: bool | None = None
component: RenderableComponent | None = None
html_content: str | None = None
class NotebookData(ContainerComponent):
@@ -42,7 +42,7 @@ class NotebookData(ContainerComponent):
def template_name(self) -> str:
return "components/core/notebook"
def _get_renderable_child_items(self):
def get_children(self) -> Iterable[RenderableComponent]:
for element in self.elements:
if element.type == "component" and element.component:
yield element
yield element.component
+17 -1
View File
@@ -2,11 +2,13 @@ from typing import Literal
from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent
__all__ = [
"BaseCell",
"ImageCell",
"ProgressBarCell",
"StatusBadgeCell",
"TableCell",
"TableData",
@@ -48,7 +50,15 @@ class StatusBadgeCell(BaseCell):
status_type: Literal["ok", "error", "warning", "info"] = "info"
TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None
class ProgressBarCell(BaseCell, ProgressBar):
"""进度条单元格,继承ProgressBar模型以复用其字段"""
type: Literal["progress_bar"] = "progress_bar" # type: ignore
TableCell = (
TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None
)
class TableData(RenderableComponent):
@@ -59,6 +69,12 @@ class TableData(RenderableComponent):
tip: str | None = Field(None, description="表格下方的提示信息")
headers: list[str] = Field(default_factory=list, description="表头列表")
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
column_alignments: list[Literal["left", "center", "right"]] | None = Field(
default=None, description="每列的对齐方式"
)
column_widths: list[str | int] | None = Field(
default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])"
)
@property
def template_name(self) -> str:
+9
View File
@@ -23,3 +23,12 @@ class TemplateComponent(RenderableComponent):
def get_render_data(self) -> dict[str, Any]:
"""返回传递给模板的数据"""
return self.data
def __getattr__(self, name: str) -> Any:
"""允许直接访问 `data` 字典中的属性。"""
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
+32
View File
@@ -0,0 +1,32 @@
from typing import Literal
from pydantic import BaseModel, Field
from .base import RenderableComponent
class TextSpan(BaseModel):
"""单个富文本片段的数据模型"""
text: str
bold: bool = False
italic: bool = False
underline: bool = False
strikethrough: bool = False
code: bool = False
color: str | None = None
font_size: str | None = None
font_family: str | None = None
class TextData(RenderableComponent):
"""轻量级富文本组件的数据模型"""
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
align: Literal["left", "right", "center"] = Field(
"left", description="整体文本对齐方式"
)
@property
def template_name(self) -> str:
return "components/core/text"