Files
zhenxun_bot/zhenxun/ui/models/core/content.py
T
5e30694663 ♻️ 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>
2026-02-06 20:13:40 +08:00

511 lines
14 KiB
Python

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