Files

511 lines
14 KiB
Python
Raw Permalink Normal View History

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