mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-09 13:50:00 +08:00
♻️ 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:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bc8e1659ae
commit
5e30694663
+144
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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""
|
||||
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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""
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user