♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理 (#2094)

* ♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理

- 【重构】重构 `RendererService` 架构,解耦模板引擎、主题管理与截图引擎
- 【重构】重构 `ui` 模块,采用组件注册机制与数据模型驱动,移除旧版 `builders`
- 【功能】新增 UI 热重载模式,支持在不重启的情况下实时预览 HTML/CSS 修改
- 【功能】增强启动项资源检查,支持基于 `resources.spec` 的版本校验与自动更新
- 【优化】统一内置插件的 UI 渲染逻辑,迁移至新的 `ui.table`、`ui.markdown` 等工厂接口
- 【优化】优化日志脱敏工具,支持自动折叠调试输出中冗长的样式标签
- 【优化】引入 `AssetResolutionService`,完善皮肤、组件、主题间的多级资源回退机制
- 【优化】新增组件生命周期钩子 `prepare`,支持渲染前的异步数据预处理

* 🚨 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
2026-02-06 20:13:40 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bc8e1659ae
commit 5e30694663
77 changed files with 3035 additions and 3258 deletions
+144 -1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Literal
from typing_extensions import Self
import uuid
from pydantic import BaseModel, Field
@@ -94,7 +95,10 @@ class BaseChartData(RenderableComponent, ABC):
class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型"""
template_path: str = Field(..., exclude=True, description="图表组件的模板路径")
class Config:
populate_by_name = True
template_path: str = Field(..., exclude=True, description="图表组件的模板路径") # type: ignore
"""图表组件的模板路径"""
title_model: EChartsTitle | None = Field(
None, alias="title", description="标题组件"
@@ -160,3 +164,142 @@ class EChartsData(BaseChartData):
@property
def template_name(self) -> str:
return self.template_path
def set_title(
self, text: str, left: Literal["left", "center", "right"] = "center"
) -> Self:
self.title_model = EChartsTitle(text=text, left=left)
return self
def set_grid(
self,
left: str | None = None,
right: str | None = None,
top: str | None = None,
bottom: str | None = None,
containLabel: bool = True,
) -> Self:
self.grid_model = EChartsGrid(
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
)
return self
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
self.tooltip_model = EChartsTooltip(trigger=trigger)
return self
def set_x_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self.x_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
def set_y_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self.y_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
def add_series(
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
) -> Self:
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
self.series_models.append(series)
return self
def set_legend(
self,
data: list[str],
orient: Literal["horizontal", "vertical"] = "horizontal",
left: str = "auto",
) -> Self:
self.legend_model = {"data": data, "orient": orient, "left": left}
return self
def set_option(self, key: str, value: Any) -> Self:
self.raw_options[key] = value
return self
def set_background_image(self, image_name: str) -> Self:
self.background_image = image_name
return self
@classmethod
def bar_chart(
cls,
title: str,
items: list[tuple[str, int | float]],
direction: Literal["horizontal", "vertical"] = "horizontal",
background_image: str | None = None,
) -> "EChartsData":
"""便捷创建一个柱状图"""
categories = [item[0] for item in items]
values = [item[1] for item in items]
if direction == "horizontal":
x_axis = EChartsAxis(type="value")
y_axis = EChartsAxis(type="category", data=categories)
else:
x_axis = EChartsAxis(type="category", data=categories)
y_axis = EChartsAxis(type="value")
return cls(
template_path="components/charts/bar_chart",
title=EChartsTitle(text=title),
grid=None,
xAxis=x_axis,
yAxis=y_axis,
tooltip=EChartsTooltip(trigger="item"),
series=[EChartsSeries(type="bar", data=values)],
background_image=background_image,
)
@classmethod
def pie_chart(
cls, title: str, items: list[tuple[str, int | float]]
) -> "EChartsData":
"""便捷创建一个饼图"""
data = [{"name": name, "value": value} for name, value in items]
legend_data = [item[0] for item in items]
return cls(
template_path="components/charts/pie_chart",
title=EChartsTitle(text=title),
grid=None,
tooltip=EChartsTooltip(trigger="item"),
xAxis=None,
yAxis=None,
legend={"data": legend_data, "orient": "horizontal", "left": "auto"},
series=[EChartsSeries(type="pie", data=data, name=title)],
background_image=None,
)
@classmethod
def line_chart(
cls, title: str, categories: list[str], series: list[dict[str, Any]]
) -> "EChartsData":
"""便捷创建一个折线图"""
series_models = [
EChartsSeries(
type="line",
name=s.get("name", ""),
data=s.get("data", []),
smooth=s.get("smooth", False),
)
for s in series
]
return cls(
template_path="components/charts/line_chart",
title=EChartsTitle(text=title),
grid=None,
xAxis=EChartsAxis(type="category", data=categories),
yAxis=EChartsAxis(type="value"),
tooltip=EChartsTooltip(trigger="axis"),
series=series_models,
background_image=None,
)
+5 -12
View File
@@ -1,18 +1,11 @@
"""
组件模型模块
包含各种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
from .data import KpiCard, Timeline, TimelineItem
from .display import Avatar, AvatarGroup, Divider, Rectangle, UserInfoBlock
from .feedback import Alert, Badge, ProgressBar
__all__ = [
"Alert",
"Avatar",
"AvatarGroup",
"Badge",
"Divider",
"KpiCard",
-27
View File
@@ -1,27 +0,0 @@
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"
-41
View File
@@ -1,41 +0,0 @@
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")
"""头像的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'"
)
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
-24
View File
@@ -1,24 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Badge"]
class Badge(RenderableComponent):
"""一个简单的徽章组件,用于显示状态或标签。"""
component_type: Literal["badge"] = "badge"
text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info",
description="预设的颜色方案",
)
"""预设的颜色方案"""
@property
def template_name(self) -> str:
return "components/widgets/badge"
@@ -1,16 +1,17 @@
from typing import Any, Literal
from pydantic import Field
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = ["KpiCard"]
__all__ = ["KpiCard", "Timeline", "TimelineItem"]
class KpiCard(RenderableComponent):
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
component_type: Literal["kpi_card"] = "kpi_card"
"""组件类型"""
label: str = Field(..., description="指标的标签或名称")
"""指标的标签或名称"""
value: Any = Field(..., description="指标的主要数值")
@@ -33,3 +34,33 @@ class KpiCard(RenderableComponent):
@property
def template_name(self) -> str:
return "components/widgets/kpi_card"
class TimelineItem(BaseModel):
"""时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
"""显示在时间点旁边的时间或标签"""
title: str = Field(..., description="事件的标题")
"""事件的标题"""
content: str = Field(..., description="事件的详细描述")
"""事件的详细描述"""
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
"""可选的自定义图标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"
+102
View File
@@ -0,0 +1,102 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Avatar", "AvatarGroup", "Divider", "Rectangle", "UserInfoBlock"]
class Avatar(RenderableComponent):
"""单个头像组件。"""
component_type: Literal["avatar"] = "avatar"
"""组件类型"""
src: str = Field(..., description="头像的URL或Base64数据URI")
"""头像的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'"
)
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class Divider(RenderableComponent):
"""一个简单的分割线组件。"""
component_type: Literal["divider"] = "divider"
"""组件类型"""
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property
def template_name(self) -> str:
return "components/widgets/divider"
class Rectangle(RenderableComponent):
"""一个矩形背景块组件。"""
component_type: Literal["rectangle"] = "rectangle"
"""组件类型"""
height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property
def template_name(self) -> str:
return "components/widgets/rectangle"
class UserInfoBlock(RenderableComponent):
"""一个带头像、名称和副标题的用户信息块组件。"""
component_type: Literal["user_info_block"] = "user_info_block"
"""组件类型"""
avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)"
)
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property
def template_name(self) -> str:
return "components/widgets/user_info_block"
-43
View File
@@ -1,43 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Divider", "Rectangle"]
class Divider(RenderableComponent):
"""一个简单的分割线组件。"""
component_type: Literal["divider"] = "divider"
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property
def template_name(self) -> str:
return "components/widgets/divider"
class Rectangle(RenderableComponent):
"""一个矩形背景块组件。"""
component_type: Literal["rectangle"] = "rectangle"
height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property
def template_name(self) -> str:
return "components/widgets/rectangle"
+70
View File
@@ -0,0 +1,70 @@
from typing import Literal
from pydantic import Field
from ...registry import component
from ..core.base import RenderableComponent
__all__ = ["Alert", "Badge", "ProgressBar"]
@component(name="alert", namespace="core")
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"
class Badge(RenderableComponent):
"""一个简单的徽章组件,用于显示状态或标签。"""
component_type: Literal["badge"] = "badge"
"""组件类型"""
text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info",
description="预设的颜色方案",
)
"""预设的颜色方案"""
@property
def template_name(self) -> str:
return "components/widgets/badge"
class ProgressBar(RenderableComponent):
"""一个进度条组件。"""
component_type: Literal["progress_bar"] = "progress_bar"
"""组件类型"""
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary",
description="预设的颜色方案",
)
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property
def template_name(self) -> str:
return "components/widgets/progress_bar"
@@ -1,28 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["ProgressBar"]
class ProgressBar(RenderableComponent):
"""一个进度条组件。"""
component_type: Literal["progress_bar"] = "progress_bar"
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary",
description="预设的颜色方案",
)
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property
def template_name(self) -> str:
return "components/widgets/progress_bar"
-36
View File
@@ -1,36 +0,0 @@
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路径")
"""可选的自定义图标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"
@@ -1,27 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["UserInfoBlock"]
class UserInfoBlock(RenderableComponent):
"""一个带头像、名称和副标题的用户信息块组件。"""
component_type: Literal["user_info_block"] = "user_info_block"
avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)"
)
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property
def template_name(self) -> str:
return "components/widgets/user_info_block"
+24 -20
View File
@@ -1,45 +1,48 @@
"""
核心模型模块
包含基础的数据模型类
"""
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 (
from .containers import (
CardData,
LayoutData,
LayoutItem,
ListData,
ListItem,
NotebookData,
NotebookElement,
TemplateComponent,
)
from .content import (
BaseCell,
CodeElement,
ComponentCell,
ComponentElement,
DetailsData,
DetailsItem,
HeadingElement,
ImageCell,
ImageElement,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
ProgressBarCell,
QuoteElement,
RawHtmlElement,
TableElement,
TextElement,
)
from .notebook import NotebookData, NotebookElement
from .table import (
BaseCell,
ComponentCell,
ImageCell,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TableElement,
TextCell,
TextData,
TextElement,
TextSpan,
)
from .template import TemplateComponent
from .text import TextData, TextSpan
__all__ = [
"BaseCell",
"CardData",
"CodeElement",
"ComponentCell",
"ComponentElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
@@ -55,6 +58,7 @@ __all__ = [
"MarkdownElement",
"NotebookData",
"NotebookElement",
"ProgressBarCell",
"QuoteElement",
"RawHtmlElement",
"RenderableComponent",
+118 -23
View File
@@ -1,22 +1,45 @@
from abc import ABC, abstractmethod
from abc import ABC
from collections.abc import Awaitable, Iterable
from typing import Any
from typing_extensions import Self
from pydantic import BaseModel
from pydantic import VERSION as PYDANTIC_VERSION
from pydantic import BaseModel, Field
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.services.renderer.types import Renderable
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
__all__ = ["ContainerComponent", "RenderableComponent"]
def _iter_renderables(obj: Any) -> Iterable["Renderable"]:
"""
递归遍历对象,查找所有 Renderable 实例。
支持列表、字典以及嵌套的 Pydantic 模型。
"""
if isinstance(obj, Renderable):
yield obj
elif isinstance(obj, list | tuple):
for item in obj:
yield from _iter_renderables(item)
elif isinstance(obj, dict):
for value in obj.values():
yield from _iter_renderables(value)
elif isinstance(obj, BaseModel):
if PYDANTIC_VERSION.startswith("1"):
fields = obj.__fields__
else:
fields = obj.model_fields # type: ignore
for field_name in fields:
value = getattr(obj, field_name)
yield from _iter_renderables(value)
class RenderableComponent(BaseModel, Renderable):
"""
所有可渲染UI组件的数据模型基类。
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
协议,确保其能够被 `RendererService` 正确处理。
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
提供通用的样式属性(如内联样式、CSS类、变体)和链式调用方法。
"""
_is_standalone_template: bool = False
@@ -29,24 +52,103 @@ class RenderableComponent(BaseModel, Renderable):
"""应用于组件根元素的额外CSS类名列表"""
variant: str | None = None
"""组件的变体/皮肤名称"""
style_name: str | None = None
"""组件的样式名称"""
is_page: bool = False
"""标记此组件是否为完整页面(自带html/body), 渲染时将跳过通用包装器"""
template_path: str | None = Field(
default=None, description="动态覆盖的模板路径", exclude=True
)
"""动态覆盖的模板路径,若设置则优先于 template_name 属性"""
@property
def template_name(self) -> str:
"""
返回用于渲染此组件的Jinja2模板的路径。
这是一个抽象属性,所有子类都必须覆盖它。
返回用于渲染此组件的 Jinja2 模板路径。
"""
raise NotImplementedError(
"Subclasses must implement the 'template_name' property."
)
return ""
def with_style(self, style_name: str) -> Self:
"""
设置组件样式名称。
参数:
style_name: 样式名称,通常对应主题中的一组CSS定义
"""
self.style_name = style_name
return self
def with_variant(self, variant: str) -> Self:
"""
设置组件变体(皮肤)。
参数:
variant: 变体名称,用于加载不同的模板或样式集
"""
self.variant = variant
return self
def with_classes(self, *classes: str) -> Self:
"""
添加 CSS 类名。
参数:
*classes: 一个或多个 CSS 类名
"""
if self.extra_classes is None:
self.extra_classes = []
self.extra_classes.extend(classes)
return self
def with_inline_style(self, style: dict[str, str]) -> Self:
"""
设置内联 CSS 样式。
参数:
style: 样式键值对字典 (e.g. {'color': 'red'})
"""
if self.inline_style is None:
self.inline_style = {}
self.inline_style.update(style)
return self
def with_component_css(self, css: str) -> Self:
"""
注入自定义 CSS 代码块。
参数:
css: CSS 代码字符串
"""
self.component_css = css
return self
def update(self, **kwargs) -> Self:
"""批量更新组件属性。"""
for k, v in kwargs.items():
if hasattr(self, k):
setattr(self, k, v)
return self
def build(self) -> Self:
"""
返回组件自身(兼容 Builder 模式调用)。
"""
return self
async def prepare(self) -> None:
"""[可选] 生命周期钩子,默认无操作。"""
"""[生命周期] 渲染前的异步准备步骤。"""
pass
def get_children(self) -> Iterable["RenderableComponent"]:
"""默认实现:非容器组件没有子组件。"""
return []
def get_children(self) -> Iterable["Renderable"]:
"""获取所有子组件的迭代器。"""
if PYDANTIC_VERSION.startswith("1"):
fields = self.__fields__
else:
fields = self.model_fields # type: ignore
for field_name in fields:
value = getattr(self, field_name)
yield from _iter_renderables(value)
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
@@ -78,13 +180,6 @@ class ContainerComponent(RenderableComponent, ABC):
一个为容器类组件设计的抽象基类,封装了预渲染子组件的通用逻辑。
"""
@abstractmethod
def get_children(self) -> Iterable[RenderableComponent]:
"""
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
"""
raise NotImplementedError
def get_required_scripts(self) -> list[str]:
"""聚合所有子组件的脚本依赖。"""
scripts = set(super().get_required_scripts())
-27
View File
@@ -1,27 +0,0 @@
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
+294
View File
@@ -0,0 +1,294 @@
import builtins
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Literal
from typing_extensions import Self
from pydantic import BaseModel, Field
from ...registry import component
from .base import ContainerComponent, Renderable, RenderableComponent
__all__ = [
"CardData",
"LayoutData",
"LayoutItem",
"ListData",
"ListItem",
"NotebookData",
"NotebookElement",
"TemplateComponent",
]
class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件"""
_is_standalone_template: bool = True
template_path: str | Path = Field(..., description="指向HTML模板文件的路径") # type: ignore
"""指向HTML模板文件的路径"""
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
"""传递给模板的上下文数据字典"""
@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
def __getattr__(self, name: str) -> Any:
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
@component(name="card", namespace="core")
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["Renderable"]:
if self.header:
yield self.header
if self.content:
yield self.content
if self.footer:
yield self.footer
def set_header(self, header: "RenderableComponent") -> Self:
self.header = header
return self
def set_footer(self, footer: "RenderableComponent") -> Self:
self.footer = footer
return self
class LayoutItem(BaseModel):
"""布局中的单个项目"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent):
"""布局构建器的数据模型"""
style_name: str | None = None
layout_type: str = "column"
children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表"
)
"""要布局的项目列表"""
options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项"
)
"""传递给模板的选项"""
@property
def template_name(self) -> str:
return f"components/core/layouts/{self.layout_type}"
@classmethod
def column(
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
) -> Self:
options.update({"gap": gap, "align_items": align_items})
return cls(layout_type="column", options=options)
@classmethod
def row(
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
) -> Self:
options.update({"gap": gap, "align_items": align_items})
return cls(layout_type="row", options=options)
@classmethod
def grid(cls, columns: int = 2, **options: Any) -> Self:
options.update({"columns": columns})
return cls(layout_type="grid", options=options)
def add_item(
self,
component: "RenderableComponent",
metadata: dict[str, Any] | None = None,
) -> Self:
self.children.append(LayoutItem(component=component, metadata=metadata))
return self
def add_option(self, key: str, value: Any) -> Self:
self.options[key] = value
return self
def get_children(self) -> Iterable["Renderable"]:
for item in self.children:
if item.component:
yield item.component
def get_extra_css(self, context: Any) -> str:
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)
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["Renderable"]:
for item in self.items:
if item.component:
yield item.component
def add_item(self, component: "RenderableComponent") -> Self:
self.items.append(ListItem(component=component))
return self
def set_ordered(self, ordered: bool = True) -> Self:
self.ordered = ordered
return self
class NotebookElement(BaseModel):
"""一个 Notebook 页面中的单个元素"""
type: Literal[
"heading",
"paragraph",
"image",
"blockquote",
"code",
"list",
"divider",
"component",
]
"""元素类型"""
text: str | None = None
"""文本内容"""
level: int | None = None
"""标题级别"""
src: str | None = None
"""图片链接"""
caption: str | None = None
"""图片说明"""
code: str | None = None
"""代码块内容"""
language: str | None = None
"""代码语言"""
data: list[str] | None = None
"""列表数据"""
ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None
"""可渲染组件"""
class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型"""
style_name: str | None = None
elements: list[NotebookElement]
@property
def template_name(self) -> str:
return "components/core/notebook"
def get_children(self) -> Iterable["Renderable"]:
for element in self.elements:
if element.component:
yield element.component
def text(self, text: str) -> Self:
self.elements.append(NotebookElement(type="paragraph", text=text))
return self
def head(self, text: str, level: int = 1) -> Self:
if not 1 <= level <= 4:
raise ValueError("标题级别必须在1-4之间")
self.elements.append(NotebookElement(type="heading", text=text, level=level))
return self
def image(self, content: str | Path, caption: str | None = None) -> Self:
src = ""
if isinstance(content, Path):
src = content.absolute().as_uri()
elif content.startswith("base64"):
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
else:
src = content
self.elements.append(NotebookElement(type="image", src=src, caption=caption))
return self
def quote(self, text: str | list[str]) -> Self:
if isinstance(text, str):
self.elements.append(NotebookElement(type="blockquote", text=text))
elif isinstance(text, list):
for t in text:
self.elements.append(NotebookElement(type="blockquote", text=t))
return self
def code(self, code: str, language: str = "python") -> Self:
self.elements.append(NotebookElement(type="code", code=code, language=language))
return self
def list(self, items: list[str], ordered: bool = False) -> Self:
self.elements.append(NotebookElement(type="list", data=items, ordered=ordered))
return self
def add_divider(self) -> Self:
self.elements.append(NotebookElement(type="divider"))
return self
def add_component(self, component: "RenderableComponent") -> Self:
self.elements.append(NotebookElement(type="component", component=component))
return self
def add_texts(self, texts: builtins.list[str]) -> Self:
for t in texts:
self.text(t)
return self
def add_quotes(self, quotes: builtins.list[str]) -> Self:
for q in quotes:
self.quote(q)
return self
+510
View File
@@ -0,0 +1,510 @@
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from pathlib import Path
from typing import Any, Literal
from typing_extensions import Self
import aiofiles
from anyio import Path as AsyncPath
from pydantic import BaseModel, Field, PrivateAttr
from zhenxun.services.log import logger
from zhenxun.ui.models.components.feedback import ProgressBar
from .base import ContainerComponent, RenderableComponent
__all__ = [
"BaseCell",
"CodeElement",
"ComponentCell",
"ComponentElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
"ImageCell",
"ImageElement",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"ProgressBarCell",
"QuoteElement",
"RawHtmlElement",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TableElement",
"TextCell",
"TextData",
"TextElement",
"TextSpan",
]
class TextSpan(BaseModel):
"""单个富文本片段的数据模型"""
text: str
"""文本内容"""
bold: bool = False
"""是否加粗"""
italic: bool = False
"""是否斜体"""
underline: bool = False
"""是否下划线"""
strikethrough: bool = False
"""是否删除线"""
code: bool = False
"""是否为等宽代码样式"""
color: str | None = None
"""文本颜色 (CSS color)"""
font_size: str | None = None
"""字体大小 (CSS font-size)"""
font_family: str | None = None
"""字体族 (CSS font-family)"""
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"
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
self.align = align
return self
def add_span(
self,
text: str,
*,
bold: bool = False,
italic: bool = False,
underline: bool = False,
strikethrough: bool = False,
code: bool = False,
color: str | None = None,
font_size: str | int | None = None,
font_family: str | None = None,
) -> Self:
font_size_str = f"{font_size}px" if isinstance(font_size, int) else font_size
span = TextSpan(
text=text,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
code=code,
color=color,
font_size=font_size_str,
font_family=font_family,
)
self.spans.append(span)
return self
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"
def add_item(self, label: str, value: Any) -> Self:
self.items.append(DetailsItem(label=label, value=str(value)))
return self
class MarkdownElement(BaseModel, ABC):
@abstractmethod
def to_markdown(self) -> str:
pass
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
"""元素类型"""
text: str
"""文本内容"""
def to_markdown(self) -> str:
return self.text
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
"""元素类型"""
text: str
"""标题文本"""
level: int = Field(..., ge=1, le=6)
"""标题级别 (1-6)"""
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
alt: str = "image"
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
language: str = ""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
rows: list[list[str]]
alignments: list[Literal["left", "center", "right"]] | None = None
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
if self.alignments:
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
separator_row = (
"| "
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
+ " |"
)
else:
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(default_factory=list)
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")])
class ListItemElement(ContainerElement):
def to_markdown(self) -> str:
return "\n".join(part.to_markdown() for part in self.content)
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
def to_markdown(self) -> str:
lines = []
for i, item in enumerate(self.content):
if isinstance(item, ListItemElement):
prefix = f"{i + 1}." if self.ordered else "*"
item_content = item.to_markdown()
lines.append(f"{prefix} {item_content}")
return "\n".join(lines)
class ComponentElement(MarkdownElement):
type: Literal["component"] = "component"
component: RenderableComponent
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""
Markdown组件数据模型。
支持链式调用构建内容,例如:
ui.markdown("").text("hello").code("print(1)")
"""
style_name: str | None = None
elements: list[MarkdownElement] = Field(default_factory=list)
"""Markdown元素列表"""
width: int = 800
"""渲染区域宽度"""
css_path: str | None = None
"""自定义CSS文件路径"""
_context_stack: list[Any] = PrivateAttr(default_factory=list)
@property
def template_name(self) -> str:
return "components/core/markdown"
async def get_extra_css(self, context: Any) -> str:
css_parts = []
if self.component_css:
css_parts.append(self.component_css)
if self.css_path:
css_file = Path(self.css_path)
if await AsyncPath(css_file).is_file():
async with aiofiles.open(css_file, encoding="utf-8") as f:
css_parts.append(await f.read())
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "light"
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
css_parts.append(await f.read())
return "\n".join(css_parts)
def set_width(self, width: int) -> Self:
self.width = width
return self
def set_css_path(self, css_path: str) -> Self:
self.css_path = css_path
return self
def _append_element(self, element: MarkdownElement) -> Self:
if self._context_stack:
self._context_stack[-1].content.append(element)
else:
self.elements.append(element)
return self
def text(self, text: str) -> Self:
return self._append_element(TextElement(text=text))
def head(self, text: str, level: int = 1) -> Self:
return self._append_element(HeadingElement(text=text, level=level))
def image(self, content: str | Path, alt: str = "image") -> Self:
src = ""
if isinstance(content, Path):
src = content.absolute().as_uri()
elif content.startswith("base64://"):
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
else:
src = content
return self._append_element(ImageElement(src=src, alt=alt))
def code(self, code: str, language: str = "") -> Self:
return self._append_element(CodeElement(code=code, language=language))
def table(
self,
headers: list[str],
rows: list[list[str]],
alignments: list[Any] | None = None,
) -> Self:
return self._append_element(
TableElement(headers=headers, rows=rows, alignments=alignments)
)
def add_divider(self) -> Self:
return self._append_element(RawHtmlElement(html="---"))
def add_component(self, component: "RenderableComponent") -> Self:
return self._append_element(ComponentElement(component=component))
class _ContextManager:
def __init__(self, model: "MarkdownData", element: Any):
self.model = model
self.element = element
def __enter__(self):
self.model._context_stack.append(self.element)
return self.model
def __exit__(self, exc_type, exc_val, exc_tb):
self.model._context_stack.pop()
def quote(self) -> AbstractContextManager["MarkdownData"]:
element = QuoteElement()
self._append_element(element)
return self._ContextManager(self, element)
def list(self, ordered: bool = False) -> AbstractContextManager["MarkdownData"]:
element = ListElement(ordered=ordered)
self._append_element(element)
return self._ContextManager(self, element)
def list_item(self) -> AbstractContextManager["MarkdownData"]:
if not self._context_stack or not isinstance(
self._context_stack[-1], ListElement
):
raise TypeError("list_item() 只能在 list() 上下文中使用。")
element = ListItemElement()
self._context_stack[-1].content.append(element)
return self._ContextManager(self, element)
class BaseCell(BaseModel):
type: str
class TextCell(BaseCell):
type: Literal["text"] = "text" # type: ignore
"""单元格类型"""
content: str
"""文本内容"""
bold: bool = False
"""是否加粗"""
color: str | None = None
"""文本颜色"""
class ImageCell(BaseCell):
type: Literal["image"] = "image" # type: ignore
"""单元格类型"""
src: str
"""图片链接"""
width: int = 40
"""显示宽度"""
height: int = 40
"""显示高度"""
shape: Literal["square", "circle"] = "square"
"""图片形状"""
alt: str = "image"
"""替换文本"""
class StatusBadgeCell(BaseCell):
type: Literal["badge"] = "badge" # type: ignore
"""单元格类型"""
text: str
"""徽章文本"""
status_type: Literal["ok", "error", "warning", "info", "success"] = "info"
"""状态类型,决定颜色"""
class ProgressBarCell(BaseCell, ProgressBar):
type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
type: Literal["rich_text"] = "rich_text" # type: ignore
"""单元格类型"""
spans: list[TextSpan] = Field(default_factory=list)
"""富文本片段列表"""
direction: Literal["column", "row"] = Field("column")
"""排列方向"""
gap: str = "4px"
"""项目间距"""
class ComponentCell(BaseCell):
type: str = "component"
component: RenderableComponent
TableCell = (
TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
)
class TableData(RenderableComponent):
style_name: str | None = None
title: str
"""表格标题"""
tip: str | None = None
"""标题旁的提示文本"""
headers: list[str] = Field(default_factory=list)
"""表格头字段列表"""
rows: list[list[TableCell]] = Field(default_factory=list)
"""数据行列表"""
column_alignments: list[Literal["left", "center", "right"]] | None = None
"""各列的对齐方式"""
column_widths: list[str | int] | None = None
"""各列的宽度限制"""
@property
def template_name(self) -> str:
return "components/core/table"
def set_headers(self, headers: list[str]) -> Self:
"""设置表格标题行"""
self.headers = headers
return self
def set_column_alignments(
self, alignments: list[Literal["left", "center", "right"]]
) -> Self:
"""设置列对齐方式"""
self.column_alignments = alignments
return self
def set_column_widths(self, widths: list[str | int]) -> Self:
"""设置列宽度"""
self.column_widths = widths
return self
def _normalize_cell(self, cell_data: Any) -> BaseCell:
"""将任意数据标准化为 TableCell 类型"""
if isinstance(cell_data, BaseCell):
return cell_data
if isinstance(cell_data, str | int | float):
return TextCell(content=str(cell_data))
if cell_data is None:
return TextCell(content="")
return TextCell(content=str(cell_data))
def add_row(self, row: list[Any]) -> Self:
"""添加单行数据"""
normalized_row = [self._normalize_cell(cell) for cell in row]
self.rows.append(normalized_row) # type: ignore
return self
def add_rows(self, rows: list[list[Any]]) -> Self:
"""批量添加多行数据"""
for row in rows:
self.add_row(row)
return self
-27
View File
@@ -1,27 +0,0 @@
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"
-58
View File
@@ -1,58 +0,0 @@
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field
from .base import ContainerComponent, RenderableComponent
__all__ = ["LayoutData", "LayoutItem"]
class LayoutItem(BaseModel):
"""布局中的单个项目,现在持有可渲染组件的数据模型"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent):
"""布局构建器的数据模型"""
style_name: str | None = None
"""应用于布局容器的样式名称"""
layout_type: str = "column"
"""布局类型 (如 'column', 'row', 'grid')"""
children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表"
)
"""要布局的项目列表"""
options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项"
)
"""传递给模板的选项"""
@property
def template_name(self) -> str:
return f"components/core/layouts/{self.layout_type}"
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
-33
View File
@@ -1,33 +0,0 @@
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
-215
View File
@@ -1,215 +0,0 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Literal
import aiofiles
from pydantic import BaseModel, Field
from zhenxun.services.log import logger
from .base import ContainerComponent, RenderableComponent
__all__ = [
"CodeElement",
"ComponentElement",
"HeadingElement",
"ImageElement",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"QuoteElement",
"RawHtmlElement",
"TableElement",
"TextElement",
]
class MarkdownElement(BaseModel, ABC):
@abstractmethod
def to_markdown(self) -> str:
"""Serializes the element to its Markdown string representation."""
pass
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
text: str
def to_markdown(self) -> str:
return self.text
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
text: str
"""标题文本"""
level: int = Field(..., ge=1, le=6, description="标题级别 (1-6)")
"""标题级别 (1-6)"""
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
"""图片来源 (URL或data URI)"""
alt: str = "image"
"""图片的替代文本"""
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
"""代码字符串"""
language: str = ""
"""代码语言,用于语法高亮"""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
"""原始HTML字符串"""
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
"""表格的表头列表"""
rows: list[list[str]]
"""表格的数据行列表"""
alignments: list[Literal["left", "center", "right"]] | None = None
"""每列的对齐方式"""
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
if self.alignments:
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
separator_row = (
"| "
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
+ " |"
)
else:
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(
default_factory=list, description="容器内包含的Markdown元素列表"
)
"""容器内包含的Markdown元素列表"""
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")])
class ListItemElement(ContainerElement):
def to_markdown(self) -> str:
return "\n".join(part.to_markdown() for part in self.content)
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
"""是否为有序列表 (例如 1., 2.)"""
def to_markdown(self) -> str:
lines = []
for i, item in enumerate(self.content):
if isinstance(item, ListItemElement):
prefix = f"{i + 1}." if self.ordered else "*"
item_content = item.to_markdown()
lines.append(f"{prefix} {item_content}")
return "\n".join(lines)
class ComponentElement(MarkdownElement):
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
type: Literal["component"] = "component"
component: RenderableComponent
"""嵌入在Markdown中的可渲染组件"""
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型"""
style_name: str | None = None
"""Markdown内容的样式名称"""
elements: list[MarkdownElement] = Field(
default_factory=list, description="构成Markdown文档的元素列表"
)
"""构成Markdown文档的元素列表"""
width: int = 800
"""最终渲染图片的宽度"""
css_path: str | None = None
"""自定义CSS文件的绝对路径"""
@property
def template_name(self) -> str:
return "components/core/markdown"
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:
css_parts = []
if self.component_css:
css_parts.append(self.component_css)
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:
css_parts.append(await f.read())
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "light"
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
css_parts.append(await f.read())
return "\n".join(css_parts)
-59
View File
@@ -1,59 +0,0 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel
from .base import ContainerComponent, RenderableComponent
__all__ = ["NotebookData", "NotebookElement"]
class NotebookElement(BaseModel):
"""一个 Notebook 页面中的单个元素"""
type: Literal[
"heading",
"paragraph",
"image",
"blockquote",
"code",
"list",
"divider",
"component",
]
text: str | None = None
"""元素的文本内容 (用于标题、段落、引用)"""
level: int | None = None
"""标题的级别 (1-4)"""
src: str | None = None
"""图片的来源 (URL或data URI)"""
caption: str | None = None
"""图片的说明文字"""
code: str | None = None
"""代码块的内容"""
language: str | None = None
"""代码块的语言"""
data: list[str] | None = None
"""列表项的内容列表"""
ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None
"""嵌入的自定义可渲染组件"""
class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型"""
style_name: str | None = None
"""Notebook的样式名称"""
elements: list[NotebookElement]
"""构成Notebook页面的元素列表"""
@property
def template_name(self) -> str:
return "components/core/notebook"
def get_children(self) -> Iterable[RenderableComponent]:
for element in self.elements:
if element.type == "component" and element.component:
yield element.component
-119
View File
@@ -1,119 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent
from .text import TextSpan
__all__ = [
"BaseCell",
"ComponentCell",
"ImageCell",
"ProgressBarCell",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TextCell",
]
class BaseCell(BaseModel):
"""单元格基础模型"""
type: str
class TextCell(BaseCell):
"""文本单元格"""
type: Literal["text"] = "text" # type: ignore
content: str
bold: bool = False
color: str | None = None
class ImageCell(BaseCell):
"""图片单元格"""
type: Literal["image"] = "image" # type: ignore
src: str
width: int = 40
height: int = 40
shape: Literal["square", "circle"] = "square"
alt: str = "image"
class StatusBadgeCell(BaseCell):
"""状态徽章单元格"""
type: Literal["badge"] = "badge" # type: ignore
text: str
status_type: Literal["ok", "error", "warning", "info"] = "info"
class ProgressBarCell(BaseCell, ProgressBar):
"""进度条单元格,继承ProgressBar模型以复用其字段"""
type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
"""富文本单元格,支持多个带样式的文本片段"""
type: Literal["rich_text"] = "rich_text" # type: ignore
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
direction: Literal["column", "row"] = Field("column", description="片段排列方向")
"""片段排列方向"""
gap: str = Field("4px", description="片段之间的间距")
"""片段之间的间距"""
class ComponentCell(BaseCell):
"""一个通用的单元格,可以容纳任何可渲染的组件。"""
type: str = "component"
component: RenderableComponent
TableCell = (
TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
)
class TableData(RenderableComponent):
"""通用表格的数据模型"""
style_name: str | None = None
"""应用于表格容器的样式名称"""
title: str = Field(..., description="表格主标题")
"""表格主标题"""
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])"
)
"""每列的宽度 (e.g., ['50px', 'auto', 100])"""
@property
def template_name(self) -> str:
return "components/core/table"
-39
View File
@@ -1,39 +0,0 @@
from pathlib import Path
from typing import Any
from pydantic import Field
from .base import RenderableComponent
__all__ = ["TemplateComponent"]
class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件"""
_is_standalone_template: bool = True
"""标记此组件为独立模板"""
template_path: str | Path = Field(..., description="指向HTML模板文件的路径")
"""指向HTML模板文件的路径"""
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
"""传递给模板的上下文数据字典"""
@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
def __getattr__(self, name: str) -> Any:
"""允许直接访问 `data` 字典中的属性。"""
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
-34
View File
@@ -1,34 +0,0 @@
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"