✨ 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
+27 -30
View File
@@ -1,70 +1,67 @@
from .charts import (
BarChartData,
BaseChartData,
LineChartData,
LineChartSeries,
PieChartData,
PieChartDataItem,
EChartsData,
)
from .components.badge import Badge
from .components.divider import Divider, Rectangle
from .components.progress_bar import ProgressBar
from .components.user_info_block import UserInfoBlock
from .core.base import RenderableComponent
from .core.layout import LayoutData, LayoutItem
from .core.markdown import (
from .components import (
Badge,
Divider,
ProgressBar,
Rectangle,
UserInfoBlock,
)
from .core import (
BaseCell,
CodeElement,
HeadingElement,
ImageCell,
ImageElement,
LayoutData,
LayoutItem,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
NotebookData,
NotebookElement,
QuoteElement,
RawHtmlElement,
TableElement,
TextElement,
)
from .core.notebook import NotebookData, NotebookElement
from .core.table import (
BaseCell,
ImageCell,
RenderableComponent,
StatusBadgeCell,
TableCell,
TableData,
TableElement,
TextCell,
TextElement,
)
from .presets import (
HelpCategory,
HelpItem,
PluginHelpPageData,
PluginMenuCategory,
PluginMenuData,
PluginMenuItem,
)
from .presets.card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .presets.help_page import HelpCategory, HelpItem, PluginHelpPageData
from .presets.plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"Badge",
"BarChartData",
"BaseCell",
"BaseChartData",
"CodeElement",
"Divider",
"EChartsData",
"HeadingElement",
"HelpCategory",
"HelpItem",
"ImageCell",
"ImageElement",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"LayoutData",
"LayoutItem",
"LineChartData",
"LineChartSeries",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"NotebookData",
"NotebookElement",
"PieChartData",
"PieChartDataItem",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",
+101 -42
View File
@@ -1,63 +1,122 @@
from typing import Literal
from abc import ABC, abstractmethod
from typing import Any, Literal
import uuid
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import model_dump
from .core.base import RenderableComponent
class BaseChartData(RenderableComponent):
class EChartsTitle(BaseModel):
text: str
left: Literal["left", "center", "right"] = "center"
class EChartsAxis(BaseModel):
type: Literal["category", "value", "time", "log"]
data: list[Any] | None = None
show: bool = True
class EChartsSeries(BaseModel):
type: str
data: list[Any]
name: str | None = None
label: dict[str, Any] | None = None
itemStyle: dict[str, Any] | None = None
barMaxWidth: int | None = None
smooth: bool | None = None
class EChartsTooltip(BaseModel):
trigger: Literal["item", "axis", "none"] = "item"
class EChartsGrid(BaseModel):
left: str | None = None
right: str | None = None
top: str | None = None
bottom: str | None = None
containLabel: bool = True
class BaseChartData(RenderableComponent, ABC):
"""所有图表数据模型的基类"""
style_name: str | None = None
title: str
chart_id: str = Field(default_factory=lambda: f"chart-{uuid.uuid4().hex}")
echarts_options: dict[str, Any] | None = None
@abstractmethod
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
raise NotImplementedError
def get_render_data(self) -> dict[str, Any]:
"""为图表组件定制渲染数据,动态构建最终的 option 对象。"""
dumped_data = model_dump(self, exclude={"template_path"})
if hasattr(self, "build_option"):
dumped_data["option"] = self.build_option()
return dumped_data
def get_required_scripts(self) -> list[str]:
"""声明此组件需要 ECharts 库。"""
return ["js/echarts.min.js"]
class BarChartData(BaseChartData):
"""柱状图(支持横向和竖向)的数据模型"""
class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型"""
category_data: list[str]
data: list[int | float]
direction: Literal["horizontal", "vertical"] = "horizontal"
background_image: str | None = None
template_path: str = Field(..., exclude=True)
title_model: EChartsTitle | None = Field(None, alias="title")
grid_model: EChartsGrid | None = Field(None, alias="grid")
tooltip_model: EChartsTooltip | None = Field(None, alias="tooltip")
x_axis_model: EChartsAxis | None = Field(None, alias="xAxis")
y_axis_model: EChartsAxis | None = Field(None, alias="yAxis")
series_models: list[EChartsSeries] = Field(default_factory=list, alias="series")
legend_model: dict[str, Any] | None = Field(default_factory=dict, alias="legend")
raw_options: dict[str, Any] = Field(
default_factory=dict, description="用于 set_option 的原始覆盖选项"
)
background_image: str | None = Field(
None, description="【兼容】用于横向柱状图的背景图片"
)
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
option: dict[str, Any] = {}
key_map = {
"title": "title_model",
"grid": "grid_model",
"tooltip": "tooltip_model",
"xAxis": "x_axis_model",
"yAxis": "y_axis_model",
"series": "series_models",
"legend": "legend_model",
}
for echarts_key, model_attr in key_map.items():
model_instance = getattr(self, model_attr, None)
if model_instance:
if isinstance(model_instance, list):
option[echarts_key] = [
model_dump(m, exclude_none=True) for m in model_instance
]
elif isinstance(model_instance, BaseModel):
option[echarts_key] = model_dump(model_instance, exclude_none=True)
else:
option[echarts_key] = model_instance
option.update(self.raw_options)
return option
@property
def title(self) -> str:
"""为模板提供一个简单的字符串标题,保持向后兼容性。"""
return self.title_model.text if self.title_model else ""
@property
def template_name(self) -> str:
return "components/charts/bar_chart"
class PieChartDataItem(BaseModel):
name: str
value: int | float
class PieChartData(BaseChartData):
"""饼图的数据模型"""
data: list[PieChartDataItem]
@property
def template_name(self) -> str:
return "components/charts/pie_chart"
class LineChartSeries(BaseModel):
name: str
data: list[int | float]
smooth: bool = False
class LineChartData(BaseChartData):
"""折线图的数据模型"""
category_data: list[str]
series: list[LineChartSeries]
@property
def template_name(self) -> str:
return "components/charts/line_chart"
return self.template_path
+7
View File
@@ -3,15 +3,22 @@
包含各种UI组件的数据模型
"""
from .alert import Alert
from .badge import Badge
from .divider import Divider, Rectangle
from .kpi_card import KpiCard
from .progress_bar import ProgressBar
from .timeline import Timeline, TimelineItem
from .user_info_block import UserInfoBlock
__all__ = [
"Alert",
"Badge",
"Divider",
"KpiCard",
"ProgressBar",
"Rectangle",
"Timeline",
"TimelineItem",
"UserInfoBlock",
]
+23
View File
@@ -0,0 +1,23 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Alert"]
class Alert(RenderableComponent):
"""一个带样式的提示框组件,用于显示重要信息。"""
component_type: Literal["alert"] = "alert"
type: Literal["info", "success", "warning", "error"] = Field(
default="info", description="提示框的类型,决定了颜色和图标"
)
title: str = Field(..., description="提示框的标题")
content: str = Field(..., description="提示框的主要内容")
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
@property
def template_name(self) -> str:
return "components/widgets/alert"
+35
View File
@@ -0,0 +1,35 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Avatar", "AvatarGroup"]
class Avatar(RenderableComponent):
"""单个头像组件。"""
component_type: Literal["avatar"] = "avatar"
src: str = Field(..., description="头像的URL或Base64数据URI")
shape: Literal["circle", "square"] = Field("circle", description="头像形状")
size: int = Field(50, description="头像尺寸(像素)")
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class AvatarGroup(RenderableComponent):
"""一组堆叠的头像组件。"""
component_type: Literal["avatar_group"] = "avatar_group"
avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
max_count: int | None = Field(
None, description="最多显示的头像数量,超出部分会显示为'+N'"
)
@property
def template_name(self) -> str:
return "components/widgets/avatar"
+29
View File
@@ -0,0 +1,29 @@
from typing import Any, Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["KpiCard"]
class KpiCard(RenderableComponent):
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
component_type: Literal["kpi_card"] = "kpi_card"
label: str = Field(..., description="指标的标签或名称")
value: Any = Field(..., description="指标的主要数值")
unit: str | None = Field(default=None, description="数值的单位,可选")
change: str | None = Field(
default=None, description="与上一周期的变化,例如 '+15%' 或 '-100'"
)
change_type: Literal["positive", "negative", "neutral"] = Field(
default="neutral", description="变化的类型,用于决定颜色"
)
icon_svg: str | None = Field(
default=None, description="卡片中显示的可选图标 (SVG path data)"
)
@property
def template_name(self) -> str:
return "components/widgets/kpi_card"
+30
View File
@@ -0,0 +1,30 @@
from typing import Literal
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = ["Timeline", "TimelineItem"]
class TimelineItem(BaseModel):
"""时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
title: str = Field(..., description="事件的标题")
content: str = Field(..., description="事件的详细描述")
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
class Timeline(RenderableComponent):
"""一个垂直时间轴组件,用于按顺序展示事件。"""
component_type: Literal["timeline"] = "timeline"
items: list[TimelineItem] = Field(
default_factory=list, description="时间轴项目列表"
)
@property
def template_name(self) -> str:
return "components/widgets/timeline"
+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"
+1 -5
View File
@@ -3,16 +3,12 @@
包含预定义的复合组件数据模型
"""
from .card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"HelpCategory",
"HelpItem",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",
-36
View File
@@ -1,36 +0,0 @@
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = [
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
]
class InfoCardMetadataItem(BaseModel):
"""信息卡片元数据项"""
label: str
value: str | int
class InfoCardSection(BaseModel):
"""信息卡片内容区块"""
title: str
content: list[str] = Field(..., description="内容段落列表")
class InfoCardData(RenderableComponent):
"""通用信息卡片的数据模型"""
style_name: str | None = None
title: str = Field(..., description="卡片主标题")
metadata: list[InfoCardMetadataItem] = Field(default_factory=list)
sections: list[InfoCardSection] = Field(default_factory=list)
@property
def template_name(self) -> str:
return "components/presets/info_card"
@@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent):
@property
def template_name(self) -> str:
return "pages/core/help_page"
return "pages/core/plugin_help_page"