mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-07 21:00:21 +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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
+1
-1
@@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "pages/core/help_page"
|
||||
return "pages/core/plugin_help_page"
|
||||
Reference in New Issue
Block a user