mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-28 16:20:56 +08:00
* ♻️ 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>
511 lines
14 KiB
Python
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""
|
|
|
|
|
|
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
|