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

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

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

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-02-06 20:13:40 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bc8e1659ae
commit 5e30694663
77 changed files with 3035 additions and 3258 deletions
+1 -1
View File
@@ -1 +1 @@
require_resources_version: ">=1.0.0"
require_resources_version: ">=1.1.0"
+51 -2
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from pathlib import Path
import uuid
import nonebot
from nonebot.adapters import Bot
from nonebot.drivers import Driver
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from tortoise import Tortoise
from tortoise.exceptions import IntegrityError, OperationalError
import ujson as json
@@ -85,8 +88,54 @@ from bag_users t1
@PriorityLifecycle.on_startup(priority=5)
async def _():
if not ZhenxunRepoManager.check_resources_exists():
await ZhenxunRepoManager.resources_update()
try:
should_update = False
resource_path = ZhenxunRepoManager.config.RESOURCE_PATH
default_theme_path = resource_path / "themes" / "default"
version_file = resource_path / "__version__"
if (
not ZhenxunRepoManager.check_resources_exists()
or not default_theme_path.exists()
or not version_file.exists()
):
should_update = True
logger.info(
"检测到资源文件(字体/主题/版本信息)缺失,准备进行初始化下载...",
"资源检查",
)
else:
spec_file = Path("resources.spec")
req_ver_str = ">=0.0.0"
if spec_file.exists():
try:
for line in spec_file.read_text("utf-8").splitlines():
if line.strip().startswith("require_resources_version:"):
req_ver_str = line.split(":", 1)[1].strip().strip("'\"")
break
except Exception:
pass
local_ver_str = "0.0.0"
try:
content = version_file.read_text("utf-8").strip()
local_ver_str = (
content.split(":", 1)[1].strip() if ":" in content else content
)
except Exception:
pass
if not SpecifierSet(req_ver_str).contains(Version(local_ver_str)):
should_update = True
logger.info(
f"资源版本({local_ver_str})不满足要求({req_ver_str}),准备强制更新...",
"资源检查",
)
if should_update:
await ZhenxunRepoManager.resources_update()
except Exception as e:
logger.error(f"资源检查或更新失败: {e}", "资源检查")
"""签到与用户的数据迁移"""
if goods_list := await GoodsInfo.filter(uuid__isnull=True).all():
for goods in goods_list:
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from typing import cast
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
@@ -21,7 +22,6 @@ from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
@@ -121,9 +121,12 @@ async def _(
if not show_quit_member:
fetch_count = count.result * 2
if rank_data := await ChatHistory.get_group_msg_rank(
raw_rank_data = await ChatHistory.get_group_msg_rank(
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
):
)
if raw_rank_data:
rank_data = cast(list[tuple[str, int]], raw_rank_data)
rows_data = []
platform = "qq"
@@ -174,10 +177,10 @@ async def _(
f"{date_scope[1].replace(microsecond=0)}"
)
builder = TableBuilder(f"消息排行({count.result})", date_str)
builder.set_headers(column_name).add_rows(rows_data)
table = ui.table(f"消息排行({count.result})", date_str)
table.set_headers(column_name).add_rows(rows_data)
image_bytes = await ui.render(builder.build())
image_bytes = await ui.render(table)
logger.info(
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
+17 -16
View File
@@ -17,11 +17,7 @@ from zhenxun.services import (
generate,
)
from zhenxun.services.log import logger
from zhenxun.ui.builders import (
NotebookBuilder,
PluginMenuBuilder,
)
from zhenxun.ui.models import PluginMenuCategory
from zhenxun.ui.models import PluginMenuCategory, PluginMenuData
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
@@ -109,18 +105,23 @@ async def create_help_img(
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
)
categories_objects = []
for category in categories_for_model:
builder.add_category(
categories_objects.append(
PluginMenuCategory(name=category["name"], items=category["items"])
)
return await ui.render(builder.build())
# 直接实例化 Data Model
menu_data = PluginMenuData(
bot_name=BotConfig.self_nickname,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
plugin_count=plugin_count,
active_count=active_count,
categories=categories_objects,
)
return await ui.render(menu_data)
async def get_user_allow_help(user_id: str) -> list[PluginType]:
@@ -299,9 +300,9 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
if len(reply_text) > threshold:
builder = NotebookBuilder()
builder.text(reply_text)
return await ui.render(builder.build())
notebook = ui.notebook()
notebook.text(reply_text)
return await ui.render(notebook)
return reply_text
@@ -1,9 +1,9 @@
from typing import Any
from zhenxun import ui
from zhenxun.services import renderer_service
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.types import ModelModality
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
@@ -33,10 +33,10 @@ class Presenters:
title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
if not models:
builder = TableBuilder(
title=title, tip="当前没有配置任何LLM模型。"
).set_headers(["提供商", "模型名称", "API类型", "状态"])
return await renderer_service.render(builder.build())
table = ui.table(title=title, tip="当前没有配置任何LLM模型。").set_headers(
["提供商", "模型名称", "API类型", "状态"]
)
return await renderer_service.render(table)
column_name = ["提供商", "模型名称", "API类型", "状态"]
rows_data = []
@@ -55,13 +55,13 @@ class Presenters:
]
)
builder = TableBuilder(
table = ui.table(
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
builder.set_headers(column_name)
builder.set_column_alignments(["left", "left", "left", "center"])
builder.add_rows(rows_data)
return await renderer_service.render(builder.build(), use_cache=True)
table.set_headers(column_name)
table.set_column_alignments(["left", "left", "left", "center"])
table.add_rows(rows_data)
return await renderer_service.render(table, use_cache=True)
@staticmethod
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
@@ -82,25 +82,25 @@ class Presenters:
if caps.is_embedding_model:
cap_list.append("文本嵌入")
builder = MarkdownBuilder()
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
builder.text("---")
builder.head("提供商信息", 2)
builder.text(f"- **名称**: {provider.name}")
builder.text(f"- **API 类型**: {provider.api_type}")
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
md = ui.markdown("")
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
md.text("---")
md.head("提供商信息", 2)
md.text(f"- **名称**: {provider.name}")
md.text(f"- **API 类型**: {provider.api_type}")
md.text(f"- **API Base**: {provider.api_base or '默认'}")
builder.head("模型详情", 2)
md.head("模型详情", 2)
temp_value = model.temperature or provider.temperature or "未设置"
token_value = model.max_tokens or provider.max_tokens or "未设置"
builder.text(f"- **名称**: {model.model_name}")
builder.text(f"- **默认温度**: {temp_value}")
builder.text(f"- **最大Token**: {token_value}")
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
md.text(f"- **名称**: {model.model_name}")
md.text(f"- **默认温度**: {temp_value}")
md.text(f"- **最大Token**: {token_value}")
md.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
return await renderer_service.render(builder.with_style("light").build())
return await renderer_service.render(md.with_style("light"))
@staticmethod
async def format_key_status_as_image(
@@ -167,10 +167,8 @@ class Presenters:
]
)
builder = TableBuilder(
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
)
builder.set_headers(
table = ui.table(title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态")
table.set_headers(
[
"Key (部分)",
"状态",
@@ -181,5 +179,5 @@ class Presenters:
"建议操作",
]
)
builder.add_rows(data_list)
return await renderer_service.render(builder.build(), use_cache=False)
table.add_rows(data_list)
return await renderer_service.render(table, use_cache=False)
@@ -3,7 +3,6 @@ from typing import Any
from zhenxun import ui
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services import scheduler_manager
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.pydantic_compat import model_json_schema
@@ -159,14 +158,14 @@ async def format_schedule_list_as_image(
if not data_list:
return "没有找到任何相关的定时任务。"
builder = TableBuilder(
table = ui.table(
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
)
builder.set_headers(
table.set_headers(
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
).add_rows(data_list)
return await ui.render(
builder.build(),
table,
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
+6 -8
View File
@@ -146,11 +146,10 @@ async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes |
else:
title = "金币全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
table = ui.table(title, tip)
table.set_headers(column_name).add_rows(data_list)
return await ui.render(table)
class ShopManage:
@@ -551,11 +550,10 @@ class ShopManage:
return None
column_name = ["-", "使用ID", "名称", "数量", "简介"]
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
builder.set_headers(column_name).add_rows(table_rows)
return await ui.render(builder.build())
table = ui.table(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
table.set_headers(column_name).add_rows(table_rows)
return await ui.render(table)
@classmethod
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
@@ -105,11 +105,10 @@ class SignManage:
else:
title = "好感度全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
table = ui.table(title, tip)
table.set_headers(column_name).add_rows(data_list)
return await ui.render(table)
@classmethod
async def sign(
@@ -21,12 +21,12 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession
from pydantic import BaseModel, ValidationError
from zhenxun import ui
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import group_settings_service, renderer_service
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.ui import builders as ui
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
@@ -254,15 +254,15 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
rows.append(row_data)
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 全群配置",
tip=f"共查询 {len(rows)} 个群组",
)
builder.set_headers(headers).add_rows(rows)
table.set_headers(headers).add_rows(rows)
viewport_width = 300 + len(config_keys) * 280
img = await renderer_service.render(
builder.build(), viewport={"width": viewport_width, "height": 10}
table, viewport={"width": viewport_width, "height": 10}
)
await MessageUtils.build_message(img).finish()
@@ -277,20 +277,20 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
f"插件 '{plugin_name_str}' 没有可配置的全局项。"
).finish()
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 全局可配置项",
tip=(
f"位于 config.yaml, 使用 pconf set <key>=<value> "
f"-p {plugin_name_str} --global 进行设置"
),
)
builder.set_headers(["配置项", "当前值", "类型", "描述"])
table.set_headers(["配置项", "当前值", "类型", "描述"])
for key, config_model in config_group.configs.items():
type_name = getattr(
config_model.type, "__name__", str(config_model.type)
)
builder.add_row(
table.add_row(
[
key,
truncate_text(str(config_model.value), 20),
@@ -299,7 +299,7 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
]
)
img = await renderer_service.render(builder.build())
img = await renderer_service.render(table)
await MessageUtils.build_message(img).finish()
else:
model = await get_plugin_config_model(plugin_name_str)
@@ -309,11 +309,11 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
f"插件 '{plugin_name_str}' 不支持分群配置。"
).finish()
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 可配置项",
tip=f"使用 pconf set <key>=<value> -p {plugin_name_str} 进行设置",
)
builder.set_headers(["配置项", "类型", "描述", "默认值"])
table.set_headers(["配置项", "类型", "描述", "默认值"])
for field in model_fields_list:
type_name = getattr(field.annotation, "__name__", str(field.annotation))
@@ -323,9 +323,9 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
if field.field_info.default is not None
else "无"
)
builder.add_row([field.name, type_name, description, default_value])
table.add_row([field.name, type_name, description, default_value])
img = await renderer_service.render(builder.build())
img = await renderer_service.render(table)
await MessageUtils.build_message(img).finish()
else:
@@ -56,6 +56,15 @@ __plugin_meta__ = PluginMetadata(
default_value=False,
type=bool,
),
RegisterConfig(
module="UI",
key="HOT_RELOAD",
value=False,
help="是否开启UI热重载模式 (修改HTML/CSS后立即生效,"
"性能较低,仅建议开发时开启)",
default_value=False,
type=bool,
),
],
).to_dict(),
)
+13 -8
View File
@@ -8,8 +8,7 @@ from zhenxun import ui
from zhenxun.configs.config import BotConfig
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.ui.builders import PluginHelpPageBuilder
from zhenxun.ui.models import HelpCategory, HelpItem
from zhenxun.ui.models import HelpCategory, HelpItem, PluginHelpPageData
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import PluginType
@@ -82,12 +81,11 @@ async def create_plugin_help_image(
)
)
builder = PluginHelpPageBuilder(
bot_nickname=BotConfig.self_nickname, page_title=page_title
)
# 直接构建 HelpCategory 列表
categories = []
for menu_type, items in grouped_plugins.items():
builder.add_category(
categories.append(
HelpCategory(
title=menu_type,
icon_svg_path="M12,2L15.09,8.26L22,9.27L17,14.14L18.18,21.02L12,17.77L5.82,21.02L7,14.14L2,9.27L8.91,8.26L12,2Z",
@@ -98,7 +96,7 @@ async def create_plugin_help_image(
task_category_data = await _get_task_category()
if task_category_data["items"]:
task_items = [HelpItem(**item) for item in task_category_data["items"]]
builder.add_category(
categories.append(
HelpCategory(
title=task_category_data["title"],
icon_svg_path=task_category_data["icon_svg_path"],
@@ -106,6 +104,13 @@ async def create_plugin_help_image(
)
)
image_bytes = await ui.render(builder.build(), use_cache=True)
# 直接实例化 Data Model
page_data = PluginHelpPageData(
bot_nickname=BotConfig.self_nickname,
page_title=page_title,
categories=categories,
)
image_bytes = await ui.render(page_data, use_cache=True)
return image_bytes
+2 -1
View File
@@ -1,6 +1,7 @@
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from .service import RendererService
from .types import Renderable, RenderResult
renderer_service = RendererService()
@@ -11,4 +12,4 @@ async def _init_renderer_service():
await renderer_service.initialize()
__all__ = ["renderer_service"]
__all__ = ["RenderResult", "Renderable", "renderer_service"]
-13
View File
@@ -1,13 +0,0 @@
"""
渲染器服务的共享配置和常量
"""
RESERVED_TEMPLATE_KEYS: set[str] = {
"data",
"theme",
"theme_css",
"extra_css",
"required_scripts",
"required_styles",
"frameless",
}
+23 -6
View File
@@ -2,10 +2,10 @@ from pathlib import Path
from nonebot_plugin_htmlrender import html_to_pic
from .protocols import ScreenshotEngine
from .types import BaseScreenshotEngine
class PlaywrightEngine(ScreenshotEngine):
class PlaywrightEngine(BaseScreenshotEngine):
"""使用 nonebot-plugin-htmlrender 实现的截图引擎。"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
@@ -26,9 +26,26 @@ class PlaywrightEngine(ScreenshotEngine):
)
def get_screenshot_engine() -> ScreenshotEngine:
class EngineManager:
"""
截图引擎工厂函数。
目前只返回 PlaywrightEngine, 未来可以根据配置返回不同的引擎。
引擎管理器,负责加载和提供具体的截图引擎实例。
未来可在此处根据 Config 读取不同的驱动配置。
"""
return PlaywrightEngine()
def __init__(self):
self._engine_class: type[BaseScreenshotEngine] = PlaywrightEngine
self._instance: BaseScreenshotEngine | None = None
async def get_engine(self) -> BaseScreenshotEngine:
if not self._instance:
self._instance = self._engine_class()
await self._instance.initialize()
return self._instance
async def close(self):
if self._instance:
await self._instance.close()
self._instance = None
engine_manager = EngineManager()
-42
View File
@@ -1,42 +0,0 @@
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field
class Theme(BaseModel):
"""
一个封装了所有主题相关信息的模型。
"""
name: str = Field(..., description="主题名称")
palette: dict[str, Any] = Field(
default_factory=dict,
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
)
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
assets_dir: Path = Field(..., description="主题的资产目录路径")
default_assets_dir: Path = Field(
..., description="默认主题的资产目录路径,用于资源回退"
)
class TemplateManifest(BaseModel):
"""
模板清单模型,用于描述一个模板的元数据。
"""
name: str = Field(..., description="模板的人类可读名称")
engine: Literal["html", "markdown"] = Field(
"html", description="渲染此模板所需的引擎"
)
entrypoint: str = Field(
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
)
styles: list[str] | str | None = Field(
None,
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
)
render_options: dict[str, Any] = Field(
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
)
-112
View File
@@ -1,112 +0,0 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Iterable
from pathlib import Path
from typing import Any, Protocol
from pydantic import BaseModel
class Renderable(ABC):
"""
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
该协议确保了所有UI组件都能被 `RendererService` 以统一的方式处理。
任何想要被渲染服务处理的UI数据模型都应直接或间接实现此协议。
"""
component_css: str | None
@property
@abstractmethod
def template_name(self) -> str:
"""
返回用于渲染此组件的Jinja2模板的路径。
这是一个抽象属性,所有子类都必须覆盖它。
返回:
str: 指向模板文件的相对路径,例如 'components/core/table'。
"""
...
async def prepare(self) -> None:
"""
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
此方法会在组件的数据被传递给模板之前调用。
适合用于执行数据库查询、网络请求等耗时操作,以准备最终的渲染数据。
"""
pass
@abstractmethod
def get_children(self) -> Iterable["Renderable"]:
"""
返回一个包含所有直接子组件的可迭代对象。
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
非容器组件应返回一个空列表。
"""
...
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
return []
@abstractmethod
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""
返回一个将传递给模板的数据字典。
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
返回:
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
"""
...
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
"""
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
可以返回 str 或 awaitable[str]。
参数:
context: 当前的渲染上下文对象,可用于访问主题管理器等。
返回:
str | Awaitable[str]: 注入到页面的额外CSS字符串。
"""
return ""
class ScreenshotEngine(Protocol):
"""
一个协议,定义了截图引擎的核心能力。
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
而无需修改上层渲染服务的代码。
"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
"""
将HTML字符串截图为图片。
参数:
html: 要渲染的HTML内容。
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
返回:
bytes: 渲染后的图片字节数据。
"""
...
class RenderResult(BaseModel):
"""
渲染服务的统一返回类型。
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
"""
image_bytes: bytes | None = None
html_content: str | None = None
-32
View File
@@ -1,32 +0,0 @@
# File: zhenxun/services/renderer/registry.py
from pathlib import Path
from typing import ClassVar
from zhenxun.services.log import logger
class AssetRegistry:
"""一个独立的、用于存储由插件动态注册的资源的单例服务。"""
_markdown_styles: ClassVar[dict[str, Path]] = {}
def register_markdown_style(self, name: str, path: Path):
"""
为 Markdown 渲染器注册一个具名样式。
参数:
name (str): 样式的唯一名称。
path (Path): 指向该样式的CSS文件路径。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
def resolve_markdown_style(self, name: str) -> Path | None:
"""解析已注册的 Markdown 样式。"""
return self._markdown_styles.get(name)
asset_registry = AssetRegistry()
+173 -288
View File
@@ -1,53 +1,36 @@
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import hashlib
import inspect
from pathlib import Path
from typing import Any, ClassVar
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
from jinja2 import TemplateNotFound
from nonebot.utils import is_coroutine_callable
import ujson as json
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
from zhenxun.configs.path_config import UI_CACHE_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.template import (
ComponentRenderStrategy,
JinjaTemplateEngine,
TemplateFileRenderStrategy,
)
from zhenxun.services.renderer.theme import DependencyCollector, asset_registry
from zhenxun.services.renderer.types import (
BaseScreenshotEngine,
Renderable,
RenderContext,
RenderResult,
RenderStrategy,
)
from zhenxun.utils.exception import RenderingError
from zhenxun.utils.log_sanitizer import sanitize_for_logging
from zhenxun.utils.pydantic_compat import _dump_pydantic_obj
from .config import RESERVED_TEMPLATE_KEYS
from .engine import get_screenshot_engine
from .protocols import Renderable, RenderResult, ScreenshotEngine
from .registry import asset_registry
from .theme import RelativePathEnvironment, ThemeManager
@dataclass
class RenderContext:
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
renderer: "RendererService"
theme_manager: ThemeManager
screenshot_engine: ScreenshotEngine
component: Renderable
use_cache: bool
render_options: dict[str, Any]
resolved_template_paths: dict[str, str] = field(default_factory=dict)
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
collected_asset_styles: set[str] = field(default_factory=set)
collected_scripts: set[str] = field(default_factory=set)
collected_inline_css: list[str] = field(default_factory=list)
processed_components: set[int] = field(default_factory=set)
from .engine import engine_manager
from .theme import ThemeManager
class RendererService:
@@ -66,9 +49,9 @@ class RendererService:
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
def __init__(self):
self._jinja_env: Environment | None = None
self._template_engine: JinjaTemplateEngine | None = None
self._theme_manager: ThemeManager | None = None
self._screenshot_engine: ScreenshotEngine | None = None
self._screenshot_engine: BaseScreenshotEngine | None = None
self._initialized = False
self._init_lock = asyncio.Lock()
self._custom_filters: dict[str, Callable] = {}
@@ -77,36 +60,6 @@ class RendererService:
self.filter("dump_json")(self._pydantic_tojson_filter)
self.global_function("inline_asset")(self._inline_asset_global)
def _create_jinja_env(self) -> Environment:
"""
创建并配置 Jinja2 渲染环境。
构建一个完整的 Jinja2 环境,包含:
- PrefixLoader:用于插件模板的命名空间加载
- FileSystemLoader:用于主题模板的文件系统加载
- RelativePathEnvironment:支持模板间相对路径引用的自定义环境
返回:
Environment: 完全配置好的 Jinja2 环境实例,准备接收自定义过滤器和全局函数。
"""
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = RelativePathEnvironment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
return env
def register_template_namespace(self, namespace: str, path: Path):
"""
为插件注册一个Jinja2模板命名空间。
@@ -116,8 +69,11 @@ class RendererService:
避免了与核心或其他插件的模板命名冲突。
参数:
namespace: 插件的唯一命名空间,例如插件名。
path: 包含该插件模板的目录路径。
namespace: 插件的唯一命名空间,建议使用插件模块名
path: 包含该插件模板的目录路径
异常:
ValueError: 当提供的路径不是有效目录时抛出
"""
if namespace in self._plugin_template_paths:
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
@@ -182,11 +138,11 @@ class RendererService:
一个Jinja2全局函数,用于读取并内联一个已注册命名空间下的资源文件内容。
主要用于内联SVG,以解决浏览器的跨域安全问题。
"""
if not self._jinja_env or not self._jinja_env.loader:
if not self._template_engine or not self._template_engine.env.loader:
return f"<!-- Error: Jinja env not ready for {namespaced_path} -->"
try:
source, _, _ = self._jinja_env.loader.get_source(
self._jinja_env, namespaced_path
source, _, _ = self._template_engine.env.loader.get_source(
self._template_engine.env, namespaced_path
)
return source
except TemplateNotFound:
@@ -205,115 +161,72 @@ class RendererService:
if self._initialized:
return
self._jinja_env = self._create_jinja_env()
try:
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
self._template_engine = JinjaTemplateEngine(
self._plugin_template_paths, auto_reload=hot_reload
)
self._jinja_env.filters.update(self._custom_filters)
self._jinja_env.globals.update(self._custom_globals)
self._template_engine.env.filters.update(self._custom_filters)
self._template_engine.env.globals.update(self._custom_globals)
self._screenshot_engine = get_screenshot_engine()
self._theme_manager = ThemeManager()
self._theme_manager.bind_template_engine(self._template_engine.env)
self._theme_manager = ThemeManager(self._jinja_env)
self._template_engine.set_global(
"render", self._theme_manager._global_render_component
)
self._template_engine.set_global(
"asset", self._theme_manager.create_asset_loader()
)
self._template_engine.set_global(
"random_asset", self._theme_manager.create_random_asset_loader()
)
self._template_engine.set_global(
"resolve_template", self._theme_manager.resolve_component_template
)
self._template_engine.set_filter(
"md", self._theme_manager._markdown_filter
)
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
self._initialized = True
self._screenshot_engine = await engine_manager.get_engine()
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
if self._theme_manager.current_theme:
self._template_engine.update_theme_loaders(
self._theme_manager.current_theme.assets_dir.parent
)
self._template_engine.set_global(
"theme", self._theme_manager.current_theme_context
)
self._template_engine.set_global(
"default_theme_palette", self._theme_manager.current_default_palette
)
self._initialized = True
except Exception as e:
logger.error(
f"渲染服务初始化失败,UI功能将不可用: {e}", "RendererService"
)
async def _collect_dependencies_recursive(
self, component: Renderable, context: "RenderContext"
):
"""
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
这是实现组件化样式和脚本管理的基础,确保即使是深层嵌套的组件
所需的资源也能被正确加载到最终的HTML页面中。
"""
component_id = id(component)
if component_id in context.processed_components:
return
context.processed_components.add(component_id)
component_path_base = str(component.template_name)
variant = getattr(component, "variant", None)
manifest = await context.theme_manager.get_template_manifest(
component_path_base, skin=variant
)
style_paths_to_load = []
if manifest and manifest.get("styles"):
styles = manifest["styles"]
styles = [styles] if isinstance(styles, str) else styles
resolution_base_path = Path(component_path_base)
if variant:
skin_manifest_path = str(Path(component_path_base) / "skins" / variant)
skin_manifest = await context.theme_manager._load_single_manifest(
skin_manifest_path
)
if skin_manifest and "styles" in skin_manifest:
resolution_base_path = Path(skin_manifest_path)
style_paths_to_load.extend(
str(resolution_base_path / style).replace("\\", "/") for style in styles
)
else:
base_template_path = (
await context.theme_manager._resolve_component_template(
component, context
)
)
base_style_path = str(
Path(base_template_path).with_name("style.css")
).replace("\\", "/")
style_paths_to_load.append(base_style_path)
if variant:
skin_style_path = f"{component_path_base}/skins/{variant}/style.css"
style_paths_to_load.append(skin_style_path)
for css_template_path in style_paths_to_load:
try:
css_template = context.theme_manager.jinja_env.get_template(
css_template_path
)
theme_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {})
}
css_content = await css_template.render_async(**theme_context)
context.collected_inline_css.append(css_content)
except TemplateNotFound:
pass
context.collected_scripts.update(component.get_required_scripts())
context.collected_asset_styles.update(component.get_required_styles())
if hasattr(component, "get_extra_css"):
res = component.get_extra_css(context)
css_str = await res if inspect.isawaitable(res) else str(res)
if css_str:
context.collected_inline_css.append(css_str)
for child in component.get_children():
if child:
await self._collect_dependencies_recursive(child, context)
await DependencyCollector.collect(component, context)
async def _render_component(
self,
context: "RenderContext",
) -> RenderResult:
"""
核心的私有渲染方法,执行完整的渲染流程。
执行步骤:
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
并尝试从文件系统中读取缓存图片。
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
异步数据加载。
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
收集所有需要的CSS文件、JS文件和内联CSS。
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
此步骤会处理独立模板和主题内模板两种情况。
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
执行完整的组件渲染流程。
包含缓存检查、组件生命周期调用、依赖收集、HTML生成、截图以及缓存写入。
"""
return await self._apply_caching_layer(self._render_component_core, context)
@@ -329,9 +242,12 @@ class RendererService:
cache_path = None
component = context.component
if Config.get_config("UI", "CACHE") and context.use_cache:
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
if Config.get_config("UI", "CACHE") and context.use_cache and not hot_reload:
try:
template_name = component.template_name
template_name = str(
getattr(component, "template_path", None) or component.template_name
)
data_dict = component.get_render_data()
resolved_data_dict = {}
for key, value in data_dict.items():
@@ -363,6 +279,7 @@ class RendererService:
if (
Config.get_config("UI", "CACHE")
and context.use_cache
and not hot_reload
and cache_path
and result.image_bytes
):
@@ -375,154 +292,79 @@ class RendererService:
return result
def _select_strategy(self, component: Renderable) -> RenderStrategy:
"""
根据组件特性选择合适的渲染策略。
"""
if getattr(component, "_is_standalone_template", False):
return TemplateFileRenderStrategy()
template_path = getattr(component, "template_path", None)
if isinstance(template_path, Path) and template_path.is_absolute():
return TemplateFileRenderStrategy()
return ComponentRenderStrategy()
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
"""
纯粹的核心渲染逻辑,不包含任何缓存处理。
此方法负责从组件数据模型生成最终的图片字节和HTML。
不含缓存处理的核心渲染逻辑,负责调度具体的渲染策略。
"""
component = context.component
try:
if not self._initialized:
await self.initialize()
assert context.theme_manager is not None, "ThemeManager 未初始化"
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
hasattr(component, "template_path")
and isinstance(
template_path := getattr(component, "template_path"),
Path,
)
and template_path.is_absolute()
):
await component.prepare()
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
temp_loader = FileSystemLoader(str(template_dir))
temp_env = Environment(
loader=temp_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
if not self._initialized or not context.screenshot_engine:
raise RenderingError(
"渲染服务未正确初始化(可能缺少资源文件),无法渲染组件。"
)
temp_env.globals.update(context.theme_manager.jinja_env.globals)
temp_env.filters.update(context.theme_manager.jinja_env.filters)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
template_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {}),
"data": data_dict,
}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 "
f"'data.{key}' 访问。"
)
else:
template_context[key] = value
html_content = await template.render_async(**template_context)
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
final_render_options = component_render_options.copy()
final_render_options.update(context.render_options)
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=template_dir,
**final_render_options,
)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
else:
await component.prepare()
await self._collect_dependencies_recursive(component, context)
data_dict = component.get_render_data()
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
manifest_options = {}
variant = getattr(component, "variant", None)
if manifest := await context.theme_manager.get_template_manifest(
component.template_name, skin=variant
):
manifest_options = manifest.get("render_options", {})
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(context.render_options)
if not context.theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
html_content = await context.theme_manager._render_component_to_html(
context,
**final_render_options,
)
screenshot_options = final_render_options.copy()
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
strategy = self._select_strategy(context.component)
return await strategy.render(context)
except RenderingError:
raise
except Exception as e:
logger.error(
f"渲染组件 '{component.__class__.__name__}' 时发生错误",
f"渲染组件 '{context.component.__class__.__name__}' 时发生错误",
"RendererService",
e=e,
)
raise RenderingError(
f"渲染组件 '{component.__class__.__name__}' 失败"
f"渲染组件 '{context.component.__class__.__name__}' 失败"
) from e
async def render(
self, component: Renderable, use_cache: bool = False, **render_options
) -> bytes:
"""
统一的、多态的渲染入口,直接返回图片字节。
将组件渲染为图片字节数据。
参数:
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
use_cache: (可选) 是否启用渲染缓存,默认为 False。
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
component: 需要渲染的 Renderable 组件实例
use_cache: 是否启用渲染缓存,默认为 False
**render_options: 传递给底层截图引擎的额外参数,如 `viewport` (字典), `device_scale_factor` 等
返回:
bytes: 渲染后的PNG图片字节数据。
bytes: 渲染后的PNG图片二进制数据
异常:
RenderingError: 当渲染流程中任何步骤失败时抛出。
"""
RenderingError: 当渲染流程中任何步骤(初始化、资源缺失、截图失败)发生错误时抛出
""" # noqa: E501
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
not self._initialized
or not self._theme_manager
or not self._screenshot_engine
or not self._template_engine
):
raise RenderingError(
"渲染服务未正确初始化(可能缺少资源文件),无法生成图片。"
)
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
template_engine=self._template_engine,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=use_cache,
@@ -557,20 +399,43 @@ class RendererService:
"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
not self._initialized
or not self._theme_manager
or not self._template_engine
or not self._screenshot_engine
):
raise RenderingError("渲染服务未正确初始化(可能缺少资源文件)。")
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
template_engine=self._template_engine,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=False,
render_options={"frameless": frameless},
)
await self._collect_dependencies_recursive(component, context)
return await self._theme_manager._render_component_to_html(
context, frameless=frameless
await component.prepare()
await DependencyCollector.collect(component, context)
resolved_template_name = await self._theme_manager.resolve_component_template(
component, context
)
theme_css_template = self._template_engine.env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=self._theme_manager.current_theme_context
)
return await self._template_engine.render_component_to_html(
component,
resolved_template_name,
self._theme_manager.current_theme_context,
theme_css_content,
context.collected_inline_css,
list(context.collected_scripts),
list(context.collected_asset_styles),
frameless=frameless,
)
async def reload_theme(self) -> str:
@@ -583,12 +448,24 @@ class RendererService:
"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
if not self._initialized or not self._theme_manager:
raise RenderingError(
"渲染服务未正确初始化(可能缺少资源文件),无法重新加载主题。"
)
self._theme_manager._manifest_cache.clear()
logger.debug("已清除UI清单缓存 (manifest cache)。")
if self._theme_manager.manifest_registry:
self._theme_manager.manifest_registry.clear_cache()
logger.debug("已清除UI清单缓存 (manifest cache)。")
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
if self._theme_manager.current_theme and self._template_engine:
self._template_engine.update_theme_loaders(
self._theme_manager.current_theme.assets_dir.parent
)
if self._template_engine and self._template_engine.env.cache:
self._template_engine.env.cache.clear()
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
return current_theme_name
@@ -616,6 +493,14 @@ class RendererService:
)
await self._theme_manager.load_theme(theme_name)
if self._theme_manager.current_theme and self._template_engine:
self._template_engine.update_theme_loaders(
self._theme_manager.current_theme.assets_dir.parent
)
if self._template_engine and self._template_engine.env.cache:
self._template_engine.env.cache.clear()
Config.set_config("UI", "THEME", theme_name, auto_save=True)
logger.info(f"UI主题已切换为: {theme_name}")
return theme_name
+270
View File
@@ -0,0 +1,270 @@
from collections.abc import Callable
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
select_autoescape,
)
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.theme import DependencyCollector
from zhenxun.services.renderer.types import (
RESERVED_TEMPLATE_KEYS,
Renderable,
RenderResult,
RenderStrategy,
)
from zhenxun.utils.exception import RenderingError
if TYPE_CHECKING:
from .types import RenderContext
class RelativePathEnvironment(Environment):
"""
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
"""
def join_path(self, template: str, parent: str) -> str:
if template.startswith("./") or template.startswith("../"):
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
return path.replace(os.path.sep, "/")
return super().join_path(template, parent)
class JinjaTemplateEngine:
"""
负责 HTML 生成的核心引擎。
"""
def __init__(
self, plugin_template_paths: dict[str, Path], auto_reload: bool = False
):
self._plugin_template_paths = plugin_template_paths
self._auto_reload = auto_reload
self.env = self._create_jinja_env()
def _create_jinja_env(self) -> Environment:
"""创建并配置 Jinja2 渲染环境"""
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = RelativePathEnvironment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
auto_reload=self._auto_reload,
)
return env
def update_theme_loaders(self, theme_dir: Path):
"""更新 Loader 以支持多主题"""
if self.env.loader and isinstance(self.env.loader, ChoiceLoader):
current_loaders = list(self.env.loader.loaders)
if len(current_loaders) > 1 and isinstance(
current_loaders[0], PrefixLoader
):
prefix_loader = current_loaders[0]
new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.env.loader.loaders = [prefix_loader, new_theme_loader]
def set_global(self, key: str, value: Any):
self.env.globals[key] = value
def set_filter(self, key: str, func: Callable):
self.env.filters[key] = func
async def render_component_to_html(
self,
component: Renderable,
template_name: str,
theme_context: dict,
theme_css_content: str,
inline_css: list[str],
scripts: list[str],
styles: list[str],
**kwargs,
) -> str:
"""
将组件数据和模板结合,生成最终的 HTML 字符串。
"""
logger.debug(
f"正在渲染组件模板: '{template_name}'",
"JinjaTemplateEngine",
)
template = self.env.get_template(template_name)
data_dict = component.get_render_data()
unpacked_data = {}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{template_name}' 中请使用 'data.{key}' 访问。"
)
else:
unpacked_data[key] = value
template_context = {
"data": component,
"theme": theme_context,
"frameless": True,
}
template_context.update(unpacked_data)
template_context.update({k: v for k, v in kwargs.items() if k != "frameless"})
html_fragment = await template.render_async(**template_context)
if not kwargs.get("frameless", False):
base_template = self.env.get_template("partials/_base.html")
page_context = {
"data": component,
"theme_css": theme_css_content,
"collected_inline_css": inline_css,
"required_scripts": scripts,
"collected_asset_styles": styles,
"body_content": html_fragment,
}
return await base_template.render_async(**page_context)
else:
style_blocks: list[str] = []
if theme_css_content:
style_blocks.append(theme_css_content)
if inline_css:
style_blocks.extend(inline_css)
if style_blocks:
css_content = "\n".join(style_blocks)
return f"<style>{css_content}</style>\n{html_fragment}"
return html_fragment
class ComponentRenderStrategy(RenderStrategy):
"""标准组件渲染策略。"""
async def render(self, context: "RenderContext") -> RenderResult:
component = context.component
await component.prepare()
await DependencyCollector.collect(component, context)
data_dict = component.get_render_data()
_opts = data_dict.get("render_options")
component_render_options = _opts if isinstance(_opts, dict) else {}
component_template_identifier = str(
getattr(component, "template_path", None) or component.template_name
)
variant = getattr(component, "variant", None)
manifest_options = {}
if manifest := await context.theme_manager.get_template_manifest(
component_template_identifier, skin=variant
):
manifest_options = manifest.render_options
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(context.render_options)
if getattr(component, "is_page", False):
final_render_options["frameless"] = True
if not context.theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
resolved_template_name = await context.theme_manager.resolve_component_template(
component, context
)
theme_css_template = context.template_engine.env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=context.theme_manager.current_theme_context
)
html_content = await context.template_engine.render_component_to_html(
component,
resolved_template_name,
context.theme_manager.current_theme_context,
theme_css_content,
context.collected_inline_css,
list(context.collected_scripts),
list(context.collected_asset_styles),
**final_render_options,
)
screenshot_options = final_render_options.copy()
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
class TemplateFileRenderStrategy(RenderStrategy):
"""独立模板文件渲染策略。"""
async def render(self, context: "RenderContext") -> RenderResult:
component = context.component
template_path = getattr(component, "template_path")
await component.prepare()
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
temp_loader = FileSystemLoader(str(template_dir))
temp_env = Environment(
loader=temp_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals.update(context.template_engine.env.globals)
temp_env.filters.update(context.template_engine.env.filters)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.globals["random_asset"] = (
context.theme_manager.create_random_asset_loader()
)
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
template_context = {
"theme": context.theme_manager.current_theme_context,
"data": data_dict,
}
for key, value in data_dict.items():
if key not in RESERVED_TEMPLATE_KEYS:
template_context[key] = value
html_content = await template.render_async(**template_context)
_opts = data_dict.get("render_options")
component_render_options = _opts if isinstance(_opts, dict) else {}
final_render_options = component_render_options.copy()
final_render_options.update(context.render_options)
if getattr(component, "is_page", False):
final_render_options["frameless"] = True
image_bytes = await context.screenshot_engine.render(
html=html_content, base_url_path=template_dir, **final_render_options
)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
"""
渲染器服务的统一类型定义文件。
合并了原 config.py, models.py, protocols.py。
"""
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, Field
if TYPE_CHECKING:
from .engine import BaseScreenshotEngine
from .service import RendererService
from .template import JinjaTemplateEngine
from .theme import ThemeManager
RESERVED_TEMPLATE_KEYS: set[str] = {
"data",
"theme",
"theme_css",
"extra_css",
"required_scripts",
"required_styles",
"frameless",
}
class Theme(BaseModel):
"""一个封装了所有主题相关信息的模型。"""
name: str = Field(..., description="主题名称")
palette: dict[str, Any] = Field(
default_factory=dict,
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
)
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
assets_dir: Path = Field(..., description="主题的资产目录路径")
default_assets_dir: Path = Field(
..., description="默认主题的资产目录路径,用于资源回退"
)
class TemplateManifest(BaseModel):
"""模板清单模型,用于描述一个模板的元数据。"""
name: str | None = Field(None, description="模板的人类可读名称")
engine: Literal["html", "markdown"] = Field(
"html", description="渲染此模板所需的引擎"
)
entrypoint: str | None = Field(
None, description="模板的入口文件 (例如 'template.html')"
)
skin: str | None = Field(None, description="默认皮肤")
styles: list[str] | str | None = Field(
None,
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
)
render_options: dict[str, Any] = Field(
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
)
class RenderResult(BaseModel):
"""渲染服务的统一返回类型。"""
image_bytes: bytes | None = None
html_content: str | None = None
class Renderable(ABC):
"""定义可被渲染UI组件必须具备的形态。"""
component_css: str | None
is_page: bool
@property
@abstractmethod
def template_name(self) -> str:
"""返回用于渲染此组件的Jinja2模板的路径。"""
...
async def prepare(self) -> None:
"""[可选] 生命周期钩子,用于在渲染前执行异步数据获取和预处理。"""
pass
@abstractmethod
def get_children(self) -> Iterable["Renderable"]:
"""返回一个包含所有直接子组件的可迭代对象。"""
...
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表。"""
return []
@abstractmethod
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""返回一个将传递给模板的数据字典。"""
...
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
"""[可选] 提供额外的CSS。"""
return ""
class BaseScreenshotEngine(ABC):
"""截图引擎的抽象基类。"""
async def initialize(self) -> None:
pass
async def close(self) -> None:
pass
@abstractmethod
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
raise NotImplementedError
class RenderStrategy(ABC):
"""渲染策略接口。"""
@abstractmethod
async def render(self, context: "RenderContext") -> "RenderResult":
raise NotImplementedError
@dataclass
class RenderContext:
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
renderer: "RendererService"
theme_manager: "ThemeManager"
template_engine: "JinjaTemplateEngine"
screenshot_engine: "BaseScreenshotEngine"
component: Renderable
use_cache: bool
render_options: dict[str, Any]
resolved_template_paths: dict[str, str] = field(default_factory=dict)
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
collected_asset_styles: set[str] = field(default_factory=set)
collected_scripts: set[str] = field(default_factory=set)
collected_inline_css: list[str] = field(default_factory=list)
processed_components: set[int] = field(default_factory=set)
+278 -56
View File
@@ -1,26 +1,61 @@
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from typing import Any, Literal, TypeVar
from zhenxun.services.renderer.protocols import Renderable
from pydantic import BaseModel
from . import builders
from .builders.core.layout import LayoutBuilder
from .models.core.base import RenderableComponent
from .models.core.markdown import MarkdownData
from .models.core.template import TemplateComponent
from zhenxun.services import renderer_service
from zhenxun.services.renderer.types import Renderable, RenderResult
from .models.components import (
Alert,
Avatar,
AvatarGroup,
Badge,
Divider,
KpiCard,
ProgressBar,
Timeline,
TimelineItem,
UserInfoBlock,
)
from .models.core import (
CardData,
DetailsData,
LayoutData,
ListData,
MarkdownData,
NotebookData,
RenderableComponent,
TableData,
TemplateComponent,
TextData,
)
from .registry import component, create
T_Model = TypeVar("T_Model", bound=BaseModel)
def register_component(namespace: str, template_dir: Path):
"""
注册一个第三方组件包(命名空间)。
注册后,可在模板中通过 `@namespace/template.html` 引用。
"""
renderer_service.register_template_namespace(namespace, template_dir)
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
"""
创建一个基于独立模板文件的UI组件。
适用于不希望遵循标准主题结构,而是直接渲染单个HTML文件的场景。
适用于不遵循标准主题结构,直接渲染单个HTML文件的场景。
参数:
path: 指向HTML模板文件的绝对或相对路径。
data: 传递给模板的上下文数据字典。
path: 指向HTML模板文件的绝对或相对路径
data: 传递给模板的上下文数据字典
返回:
TemplateComponent: 一个可被 `render()` 函数处理的组件实例。
TemplateComponent: 可被 `render()` 函数处理的组件实例
"""
if isinstance(path, str):
path = Path(path)
@@ -28,7 +63,7 @@ def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
return TemplateComponent(template_path=path, data=data)
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
def markdown(content: str = "", style: str | Path | None = "default") -> MarkdownData:
"""
创建一个基于Markdown内容的UI组件。
@@ -38,10 +73,12 @@ def markdown(content: str, style: str | Path | None = "default") -> MarkdownData
自定义CSS文件的路径。
返回:
MarkdownData: 一个可被 `render()` 函数处理的组件实例。
MarkdownData: Markdown 组件实例
"""
builder = builders.MarkdownBuilder().text(content)
component = builder.build()
component = MarkdownData()
if content:
component.text(content)
if isinstance(style, Path):
component.css_path = str(style.absolute())
else:
@@ -49,47 +86,195 @@ def markdown(content: str, style: str | Path | None = "default") -> MarkdownData
return component
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
def table(title: str, tip: str | None = None) -> TableData:
"""
创建一个表格组件构建器。
支持链式调用,例如: `ui.table("Title").set_headers(["A", "B"]).add_row([1, 2])`
参数:
title: 表格标题
tip: 标题旁的提示文本(可选)
"""
return TableData(title=title, tip=tip, headers=[], rows=[])
def notebook(data: list[Any] | None = None) -> NotebookData:
"""
创建一个 Notebook 文档构建器。
参数:
data: 初始元素列表(可选)
"""
return NotebookData(elements=data or []) # type: ignore
def alert(
title: str,
content: str,
type: Literal["info", "success", "warning", "error"] = "info",
) -> Alert:
"""创建 Alert 组件"""
return Alert(
title=title,
content=content,
type=type,
)
def badge(
text: str,
color_scheme: Literal["primary", "success", "warning", "error", "info"] = "info",
) -> Badge:
"""创建 Badge 组件"""
return Badge(
text=text,
color_scheme=color_scheme,
)
def divider(
margin: str = "2em 0",
color: str = "#f7889c",
style: Literal["solid", "dashed", "dotted"] = "solid",
thickness: str = "1px",
) -> Divider:
"""创建 Divider 组件"""
return Divider(
margin=margin,
color=color,
style=style,
thickness=thickness,
)
def progress_bar(
progress: float,
label: str | None = None,
color_scheme: Literal["primary", "success", "warning", "error", "info"] = "primary",
animated: bool = False,
) -> ProgressBar:
"""
创建 ProgressBar 进度条组件。
支持多种预设颜色方案和动画效果。
"""
return ProgressBar(
progress=progress,
label=label,
color_scheme=color_scheme,
animated=animated,
)
def kpi_card(label: str, value: Any, **kwargs) -> KpiCard:
"""创建 KPI 卡片"""
return KpiCard(label=label, value=value, **kwargs)
def text(
text: str,
align: Literal["left", "right", "center"] = "left",
**kwargs,
) -> TextData:
"""
创建纯文本组件。
参数:
text: 文本内容
align: 对齐方式
**kwargs: 传递给首个文本片段的样式参数 (如 bold=True, color='red')
返回:
TextData: 支持链式调用 .add_span() 的文本模型
"""
model = TextData(align=align)
if text:
model.add_span(text, **kwargs)
return model
def card(content: RenderableComponent) -> CardData:
"""创建 Card 组件"""
return CardData(content=content)
def avatar(
src: str, shape: Literal["circle", "square"] = "circle", size: int = 50
) -> Avatar:
return Avatar(src=src, shape=shape, size=size)
def avatar_group(
avatars: list[Avatar | str] | None = None,
spacing: int = -15,
max_count: int | None = None,
) -> AvatarGroup:
"""
创建头像组。
avatars: 头像对象或URL列表。
"""
avatar_objs = []
if avatars:
for item in avatars:
if isinstance(item, str):
avatar_objs.append(Avatar(src=item)) # type: ignore
else:
avatar_objs.append(item)
return AvatarGroup(avatars=avatar_objs, spacing=spacing, max_count=max_count)
def timeline(items: list[dict[str, Any]] | None = None) -> Timeline:
"""
创建时间轴。
items: 包含 timestamp, title, content, icon, color 的字典列表。
"""
timeline_items = []
if items:
for item in items:
timeline_items.append(TimelineItem(**item))
return Timeline(items=timeline_items)
def user_info_block(
name: str,
avatar_url: str,
subtitle: str | None = None,
tags: list[str] | None = None,
) -> UserInfoBlock:
return UserInfoBlock(
name=name,
avatar_url=avatar_url,
subtitle=subtitle,
tags=tags or [],
)
def vstack(children: Sequence[Any], **layout_options) -> LayoutData:
"""
创建一个垂直布局组件。
便捷函数,用于将多个组件垂直堆叠。
参数:
children: 一个包含 `RenderableComponent` 实例的列表。
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
返回:
LayoutBuilder: 一个配置好的垂直布局构建器。
"""
builder = LayoutBuilder.column(**layout_options)
layout = LayoutData.column(**layout_options)
for child in children:
builder.add_item(child)
return builder
layout.add_item(child)
return layout
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
def hstack(children: Sequence[Any], **layout_options) -> LayoutData:
"""
创建一个水平布局组件。
便捷函数,用于将多个组件水平排列。
参数:
children: 一个包含 `RenderableComponent` 实例的列表。
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
返回:
LayoutBuilder: 一个配置好的水平布局构建器。
"""
builder = LayoutBuilder.row(**layout_options)
layout = LayoutData.row(**layout_options)
for child in children:
builder.add_item(child)
return builder
layout.add_item(child)
return layout
async def render(
component_or_path: Renderable | str | Path,
data: dict | None = None,
*,
template: str | Path | None = None,
use_cache: bool = False,
is_page: bool = False,
**kwargs,
) -> bytes:
"""
@@ -99,32 +284,51 @@ async def render(
用法:
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
3. 动态替换模板: `render(my_data, template="plugins/my_plugin/custom_card")`
参数:
component_or_path: 一个 `Renderable` 实例,或一个指向模板文件的
`str` 或 `Path` 对象。
data: (可选) 当 `component_or_path` 是路径时,必须提供此数据字典。
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
**kwargs: 传递给底层截图引擎的额外参数,例如 `viewport`。
`str` 或 `Path` 对象
data: 当 `component_or_path` 是路径时,必须提供此数据字典
template: 强制使用的模板路径,覆盖组件默认设置(可选)
use_cache: 是否启用渲染结果缓存(默认为 False)
is_page: 标记此次渲染是否为完整页面(自带html/body),默认为 False
**kwargs: 传递给截图引擎的参数,如 `viewport={"width": 800, "height": 600}`
返回:
bytes: 渲染后的PNG图片字节数据。
bytes: PNG图片二进制数据
"""
from zhenxun.services import renderer_service
component: Renderable
if isinstance(component_or_path, str | Path):
if data is None:
raise ValueError("使用模板路径渲染时必须提供 'data' 参数。")
component = TemplateComponent(template_path=component_or_path, data=data)
component = TemplateComponent(
template_path=component_or_path, data=data, is_page=is_page
)
else:
component = component_or_path
if template:
if isinstance(template, Path):
template_str = template.as_posix()
else:
template_str = str(template).replace("\\", "/")
if hasattr(component, "template_path"):
setattr(component, "template_path", template_str)
if is_page and hasattr(component, "is_page"):
component.is_page = True
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
async def render_template(
path: str | Path, data: dict, use_cache: bool = False, **kwargs
path: str | Path,
data: dict,
use_cache: bool = False,
*,
is_page: bool = True,
**kwargs,
) -> bytes:
"""
渲染一个独立的Jinja2模板文件。
@@ -135,6 +339,8 @@ async def render_template(
path: 模板文件路径,相对于主题模板目录。
data: 传递给模板的数据字典。
use_cache: (可选) 是否启用渲染缓存,默认为 False。
is_page: (可选) 标记该模板是否为完整页面。默认为 True,因为独立模板通常已
包含文档骨架。
**kwargs: 传递给渲染服务的额外参数。
返回:
@@ -143,7 +349,7 @@ async def render_template(
异常:
RenderingError: 渲染失败时抛出。
"""
return await render(path, data, use_cache=use_cache, **kwargs)
return await render(path, data, use_cache=use_cache, is_page=is_page, **kwargs)
async def render_markdown(
@@ -166,8 +372,9 @@ async def render_markdown(
异常:
RenderingError: 渲染失败时抛出。
"""
builder = builders.MarkdownBuilder().text(md)
component = builder.build()
component = MarkdownData()
component.text(md)
if isinstance(style, Path):
component.css_path = str(style.absolute())
else:
@@ -176,9 +383,6 @@ async def render_markdown(
return await render(component, use_cache=use_cache, **kwargs)
from zhenxun.services.renderer.protocols import RenderResult
async def render_full_result(
component: Renderable, use_cache: bool = False, **kwargs
) -> RenderResult:
@@ -194,17 +398,18 @@ async def render_full_result(
返回:
RenderResult: 一个包含 `image_bytes` 和 `html_content` 的Pydantic模型。
"""
from zhenxun.services import renderer_service
from zhenxun.services.renderer.service import RenderContext
if not renderer_service._initialized:
await renderer_service.initialize()
assert renderer_service._theme_manager is not None, "ThemeManager 未初始化"
assert renderer_service._screenshot_engine is not None, "ScreenshotEngine 未初始化"
assert renderer_service._template_engine is not None, "TemplateEngine 未初始化"
context = RenderContext(
renderer=renderer_service,
theme_manager=renderer_service._theme_manager,
template_engine=renderer_service._template_engine,
screenshot_engine=renderer_service._screenshot_engine,
component=component,
use_cache=use_cache,
@@ -214,13 +419,30 @@ async def render_full_result(
__all__ = [
"builders",
"DetailsData",
"ListData",
"alert",
"avatar",
"avatar_group",
"badge",
"card",
"component",
"create",
"divider",
"hstack",
"kpi_card",
"markdown",
"notebook",
"progress_bar",
"register_component",
"render",
"render_full_result",
"render_markdown",
"render_template",
"table",
"template",
"text",
"timeline",
"user_info_block",
"vstack",
]
-49
View File
@@ -1,49 +0,0 @@
from .charts import EChartsBuilder
from .components import (
AlertBuilder,
AvatarBuilder,
AvatarGroupBuilder,
BadgeBuilder,
DividerBuilder,
KpiCardBuilder,
ProgressBarBuilder,
TimelineBuilder,
UserInfoBlockBuilder,
)
from .core import (
CardBuilder,
DetailsBuilder,
LayoutBuilder,
ListBuilder,
MarkdownBuilder,
NotebookBuilder,
TableBuilder,
TextBuilder,
)
from .presets import (
PluginHelpPageBuilder,
PluginMenuBuilder,
)
__all__ = [
"AlertBuilder",
"AvatarBuilder",
"AvatarGroupBuilder",
"BadgeBuilder",
"CardBuilder",
"DetailsBuilder",
"DividerBuilder",
"EChartsBuilder",
"KpiCardBuilder",
"LayoutBuilder",
"ListBuilder",
"MarkdownBuilder",
"NotebookBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
"ProgressBarBuilder",
"TableBuilder",
"TextBuilder",
"TimelineBuilder",
"UserInfoBlockBuilder",
]
-117
View File
@@ -1,117 +0,0 @@
from typing import Generic, TypeVar
from typing_extensions import Self
from pydantic import BaseModel
T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
class BaseBuilder(Generic[T_DataModel]):
"""
所有UI构建器的通用基类。
它实现了Builder设计模式,提供了一个流畅的、链式调用的API来创建和配置UI组件的数据模型。
同时,它也提供了通用的样式化方法,如 `with_style`, `with_inline_style` 等。
参数:
T_DataModel: 与此构建器关联的 Pydantic 数据模型类型。
"""
def __init__(self, data_model: T_DataModel, template_name: str):
self._data: T_DataModel = data_model
self._style_name: str | None = None
self._template_name = template_name
self._inline_style: dict | None = None
self._component_css: str | None = None
self._variant: str | None = None
self._extra_classes: list[str] = []
@property
def data(self) -> T_DataModel:
return self._data
def with_style(self, style_name: str) -> Self:
"""
为组件应用一个特定的样式。
参数:
style_name: 在主题的CSS中定义的样式类名。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._style_name = style_name
return self
def with_inline_style(self, style: dict[str, str]) -> Self:
"""
为组件的根元素应用动态的内联样式。
参数:
style: 一个CSS样式字典,例如
`{"background-color":"#fff","font-size":"16px"}`。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._inline_style = style
return self
def with_variant(self, variant_name: str) -> Self:
"""
为组件应用一个特定的变体/皮肤。
参数:
variant_name: 在组件的 `skins/` 目录下定义的变体名称。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._variant = variant_name
return self
def with_component_css(self, css: str) -> Self:
"""
向页面注入一段自定义的CSS样式字符串。
参数:
css: 包含CSS规则的字符串。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._component_css = css
return self
def with_classes(self, *class_names: str) -> Self:
"""
为组件的根元素添加一个或多个CSS工具类。
这些类来自主题预定义的工具集。
示例: .with_classes("p-4", "text-center", "font-bold")
"""
self._extra_classes.extend(class_names)
return self
def build(self) -> T_DataModel:
"""
构建并返回配置好的数据模型。
这是构建过程的最后一步,它会将所有配置应用到数据模型上。
返回:
T_DataModel: 最终配置好的、可被渲染服务使用的数据模型实例。
"""
if self._style_name and hasattr(self._data, "style_name"):
setattr(self._data, "style_name", self._style_name)
if self._inline_style and hasattr(self._data, "inline_style"):
setattr(self._data, "inline_style", self._inline_style)
if self._component_css and hasattr(self._data, "component_css"):
setattr(self._data, "component_css", self._component_css)
if self._variant and hasattr(self._data, "variant"):
setattr(self._data, "variant", self._variant)
if self._extra_classes and hasattr(self._data, "extra_classes"):
setattr(self._data, "extra_classes", self._extra_classes)
return self._data
-177
View File
@@ -1,177 +0,0 @@
from typing import Any, Generic, Literal, TypeVar
from typing_extensions import Self
from ..models.charts import (
BaseChartData,
EChartsAxis,
EChartsData,
EChartsGrid,
EChartsSeries,
EChartsTitle,
EChartsTooltip,
)
from .base import BaseBuilder
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
"""
一个统一的、泛型的 ECharts 图表构建器。
提供了设置 ECharts `option` 的核心方法,以及一些常用图表的便利方法。
"""
def __init__(self, template_name: str, title: str):
model = EChartsData(
template_path=template_name,
title=EChartsTitle(text=title),
grid=None,
tooltip=None,
xAxis=None,
yAxis=None,
legend=None,
background_image=None,
)
super().__init__(model, template_name=template_name)
def set_title(
self, text: str, left: Literal["left", "center", "right"] = "center"
) -> Self:
self._data.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._data.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._data.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._data.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._data.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._data.series_models.append(series)
return self
def set_legend(
self,
data: list[str],
orient: Literal["horizontal", "vertical"] = "horizontal",
left: str = "auto",
) -> Self:
self._data.legend_model = {"data": data, "orient": orient, "left": left}
return self
def set_option(self, key: str, value: Any) -> Self:
"""
[高级] 设置 ECharts `option` 中的一个原始键值对。
这会覆盖由其他流畅API方法设置的同名配置。
"""
self._data.raw_options[key] = value
return self
def set_background_image(self, image_name: str) -> Self:
"""【兼容】为横向柱状图设置背景图片。"""
self._data.background_image = image_name
return self
def bar_chart(
title: str,
items: list[tuple[str, int | float]],
direction: Literal["horizontal", "vertical"] = "horizontal",
) -> EChartsBuilder:
"""便捷工厂函数:创建一个柱状图构建器。"""
builder = EChartsBuilder("components/charts/bar_chart", title)
categories = [item[0] for item in items]
values = [item[1] for item in items]
if direction == "horizontal":
builder.set_x_axis(type="value")
builder.set_y_axis(type="category", data=categories)
builder.add_series(
type="bar",
data=values,
)
else:
builder.set_x_axis(type="category", data=categories)
builder.set_y_axis(type="value")
builder.add_series(type="bar", data=values)
return builder
def pie_chart(title: str, items: list[tuple[str, int | float]]) -> EChartsBuilder:
"""便捷工厂函数:创建一个饼图构建器。"""
builder = EChartsBuilder("components/charts/pie_chart", title)
data = [{"name": name, "value": value} for name, value in items]
legend_data = [item[0] for item in items]
builder.set_legend(data=legend_data)
builder.add_series(
name=title,
type="pie",
data=data,
)
return builder
def line_chart(
title: str, categories: list[str], series: list[dict[str, Any]]
) -> EChartsBuilder:
"""便捷工厂函数:创建一个折线图构建器。"""
builder = EChartsBuilder("components/charts/line_chart", title)
builder.set_x_axis(type="category", data=categories)
builder.set_y_axis(type="value")
for s in series:
builder.add_series(
type="line",
name=s.get("name", ""),
data=s.get("data", []),
smooth=s.get("smooth", False),
)
return builder
def radar_chart(
title: str, indicators: list[tuple[str, int | float]], series: list[dict[str, Any]]
) -> EChartsBuilder:
"""便捷工厂函数:创建一个雷达图构建器。"""
builder = EChartsBuilder("components/charts/radar_chart", title)
legend_data = [s.get("name", "") for s in series]
radar_indicators = [{"name": name, "max": max_val} for name, max_val in indicators]
builder.set_legend(data=legend_data)
builder.set_option("radar", {"indicator": radar_indicators})
builder.add_series(type="radar", data=series)
return builder
@@ -1,25 +0,0 @@
"""
小组件构建器模块
包含各种UI小组件的构建器
"""
from .alert import AlertBuilder
from .avatar import AvatarBuilder, AvatarGroupBuilder
from .badge import BadgeBuilder
from .divider import DividerBuilder
from .kpi_card import KpiCardBuilder
from .progress_bar import ProgressBarBuilder
from .timeline import TimelineBuilder
from .user_info_block import UserInfoBlockBuilder
__all__ = [
"AlertBuilder",
"AvatarBuilder",
"AvatarGroupBuilder",
"BadgeBuilder",
"DividerBuilder",
"KpiCardBuilder",
"ProgressBarBuilder",
"TimelineBuilder",
"UserInfoBlockBuilder",
]
-23
View File
@@ -1,23 +0,0 @@
from typing import Literal
from typing_extensions import Self
from ...models.components.alert import Alert
from ..base import BaseBuilder
class AlertBuilder(BaseBuilder[Alert]):
"""链式构建提示/标注框组件的辅助类"""
def __init__(
self,
title: str,
content: str,
type: Literal["info", "success", "warning", "error"] = "info",
):
data_model = Alert(title=title, content=content, type=type)
super().__init__(data_model, template_name="components/widgets/alert")
def hide_icon(self) -> Self:
"""隐藏提示框的默认图标"""
self._data.show_icon = False
return self
-38
View File
@@ -1,38 +0,0 @@
from typing import Literal
from typing_extensions import Self
from ...models.components.avatar import Avatar, AvatarGroup
from ..base import BaseBuilder
class AvatarBuilder(BaseBuilder[Avatar]):
"""链式构建单个头像的辅助类"""
def __init__(self, src: str):
data_model = Avatar(src=src, shape="circle", size=50)
super().__init__(data_model, template_name="components/widgets/avatar")
def set_shape(self, shape: Literal["circle", "square"]) -> Self:
self._data.shape = shape
return self
def set_size(self, size: int) -> Self:
self._data.size = size
return self
class AvatarGroupBuilder(BaseBuilder[AvatarGroup]):
"""链式构建头像组的辅助类"""
def __init__(self):
data_model = AvatarGroup(avatars=[], spacing=-15, max_count=None)
super().__init__(data_model, template_name="components/widgets/avatar_group")
def add_avatar(self, avatar: Avatar | AvatarBuilder | str) -> Self:
if isinstance(avatar, str):
self._data.avatars.append(Avatar(src=avatar, shape="circle", size=50))
elif isinstance(avatar, AvatarBuilder):
self._data.avatars.append(avatar.build())
else:
self._data.avatars.append(avatar)
return self
-25
View File
@@ -1,25 +0,0 @@
from typing import Literal
from ...models.components.badge import Badge
from ..base import BaseBuilder
class BadgeBuilder(BaseBuilder[Badge]):
"""链式构建徽章组件的辅助类"""
def __init__(
self,
text: str,
color_scheme: Literal[
"primary", "success", "warning", "error", "info"
] = "info",
):
data_model = Badge(text=text, color_scheme=color_scheme)
super().__init__(data_model, template_name="components/widgets/badge")
def set_color_scheme(
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
) -> "BadgeBuilder":
"""设置徽章的颜色方案。"""
self._data.color_scheme = color_scheme
return self
-20
View File
@@ -1,20 +0,0 @@
from typing import Literal
from ...models.components.divider import Divider
from ..base import BaseBuilder
class DividerBuilder(BaseBuilder[Divider]):
"""链式构建分割线组件的辅助类"""
def __init__(
self,
margin: str = "2em 0",
color: str = "#f7889c",
style: Literal["solid", "dashed", "dotted"] = "solid",
thickness: str = "1px",
):
data_model = Divider(
margin=margin, color=color, style=style, thickness=thickness
)
super().__init__(data_model, template_name="components/widgets/divider")
@@ -1,31 +0,0 @@
from typing import Any, Literal
from typing_extensions import Self
from ...models.components.kpi_card import KpiCard
from ..base import BaseBuilder
class KpiCardBuilder(BaseBuilder[KpiCard]):
"""链式构建统计卡片(KPI Card)的辅助类"""
def __init__(self, label: str, value: Any):
data_model = KpiCard(label=label, value=value)
super().__init__(data_model, template_name="components/widgets/kpi_card")
def with_unit(self, unit: str) -> Self:
"""设置数值的单位"""
self._data.unit = unit
return self
def with_change(
self, change: str, type: Literal["positive", "negative", "neutral"] = "neutral"
) -> Self:
"""设置与上一周期的变化率"""
self._data.change = change
self._data.change_type = type
return self
def with_icon(self, svg_path: str) -> Self:
"""设置卡片图标 (提供SVG path data)"""
self._data.icon_svg = svg_path
return self
@@ -1,42 +0,0 @@
from typing import Literal
from ...models.components.progress_bar import ProgressBar
from ..base import BaseBuilder
class ProgressBarBuilder(BaseBuilder[ProgressBar]):
"""链式构建进度条组件的辅助类"""
def __init__(
self,
progress: float,
label: str | None = None,
color_scheme: Literal[
"primary", "success", "warning", "error", "info"
] = "primary",
animated: bool = False,
):
data_model = ProgressBar(
progress=progress,
label=label,
color_scheme=color_scheme,
animated=animated,
)
super().__init__(data_model, template_name="components/widgets/progress_bar")
def set_label(self, label: str) -> "ProgressBarBuilder":
"""设置进度条上显示的文本。"""
self._data.label = label
return self
def set_color_scheme(
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
) -> "ProgressBarBuilder":
"""设置进度条的颜色方案。"""
self._data.color_scheme = color_scheme
return self
def set_animated(self, animated: bool = True) -> "ProgressBarBuilder":
"""设置进度条是否显示动画效果。"""
self._data.animated = animated
return self
@@ -1,28 +0,0 @@
from typing_extensions import Self
from ...models.components.timeline import Timeline, TimelineItem
from ..base import BaseBuilder
class TimelineBuilder(BaseBuilder[Timeline]):
"""链式构建时间轴组件的辅助类"""
def __init__(self):
data_model = Timeline(items=[])
super().__init__(data_model, template_name="components/widgets/timeline")
def add_item(
self,
timestamp: str,
title: str,
content: str,
*,
icon: str | None = None,
color: str | None = None,
) -> Self:
"""向时间轴中添加一个事件点"""
item = TimelineItem(
timestamp=timestamp, title=title, content=content, icon=icon, color=color
)
self._data.items.append(item)
return self
@@ -1,33 +0,0 @@
from ...models.components.user_info_block import UserInfoBlock
from ..base import BaseBuilder
class UserInfoBlockBuilder(BaseBuilder[UserInfoBlock]):
"""链式构建用户信息块的辅助类"""
def __init__(
self,
name: str,
avatar_url: str,
subtitle: str | None = None,
tags: list[str] | None = None,
):
data_model = UserInfoBlock(
name=name, avatar_url=avatar_url, subtitle=subtitle, tags=tags or []
)
super().__init__(data_model, template_name="components/widgets/user_info_block")
def set_subtitle(self, subtitle: str) -> "UserInfoBlockBuilder":
"""设置副标题。"""
self._data.subtitle = subtitle
return self
def add_tag(self, tag: str) -> "UserInfoBlockBuilder":
"""添加一个标签。"""
self._data.tags.append(tag)
return self
def add_tags(self, tags: list[str]) -> "UserInfoBlockBuilder":
"""批量添加标签。"""
self._data.tags.extend(tags)
return self
-24
View File
@@ -1,24 +0,0 @@
"""
核心构建器模块
包含基础的UI构建器类
"""
from .card import CardBuilder
from .details import DetailsBuilder
from .layout import LayoutBuilder
from .list import ListBuilder
from .markdown import MarkdownBuilder
from .notebook import NotebookBuilder
from .table import TableBuilder
from .text import TextBuilder
__all__ = [
"CardBuilder",
"DetailsBuilder",
"LayoutBuilder",
"ListBuilder",
"MarkdownBuilder",
"NotebookBuilder",
"TableBuilder",
"TextBuilder",
]
-26
View File
@@ -1,26 +0,0 @@
from typing_extensions import Self
from ...models.core.base import RenderableComponent
from ...models.core.card import CardData
from ..base import BaseBuilder
class CardBuilder(BaseBuilder[CardData]):
"""链式构建通用卡片容器的辅助类"""
def __init__(self, content: "RenderableComponent | BaseBuilder"):
content_model = content.build() if isinstance(content, BaseBuilder) else content
data_model = CardData(content=content_model)
super().__init__(data_model, template_name="components/core/card")
def set_header(self, header: "RenderableComponent | BaseBuilder") -> Self:
"""设置卡片的头部组件"""
header_model = header.build() if isinstance(header, BaseBuilder) else header
self._data.header = header_model
return self
def set_footer(self, footer: "RenderableComponent | BaseBuilder") -> Self:
"""设置卡片的尾部组件"""
footer_model = footer.build() if isinstance(footer, BaseBuilder) else footer
self._data.footer = footer_model
return self
-19
View File
@@ -1,19 +0,0 @@
from typing import Any
from typing_extensions import Self
from ...models.core.details import DetailsData, DetailsItem
from ..base import BaseBuilder
class DetailsBuilder(BaseBuilder[DetailsData]):
"""链式构建描述列表(键值对)的辅助类"""
def __init__(self, title: str | None = None):
data_model = DetailsData(title=title, items=[])
super().__init__(data_model, template_name="components/core/details")
def add_item(self, label: str, value: Any) -> Self:
"""向列表中添加一个键值对项目"""
value_str = str(value)
self._data.items.append(DetailsItem(label=label, value=value_str))
return self
-121
View File
@@ -1,121 +0,0 @@
from typing import Any
from typing_extensions import Self
from ...models.core.base import RenderableComponent
from ...models.core.layout import LayoutData, LayoutItem
from ..base import BaseBuilder
__all__ = ["LayoutBuilder"]
class LayoutBuilder(BaseBuilder[LayoutData]):
"""
一个用于将多个UI组件组合成单张图片的链式构建器。
它通过在单个渲染流程中动态包含子模板来实现高质量的输出。
"""
def __init__(self):
super().__init__(LayoutData(), template_name="")
self._options: dict[str, Any] = {}
@classmethod
def column(
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
) -> Self:
builder = cls()
builder._template_name = "components/core/layouts/column"
builder._options["gap"] = gap
builder._options["align_items"] = align_items
builder._options.update(options)
return builder
@classmethod
def row(
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
) -> Self:
builder = cls()
builder._template_name = "components/core/layouts/row"
builder._options["gap"] = gap
builder._options["align_items"] = align_items
builder._options.update(options)
return builder
@classmethod
def grid(cls, columns: int = 2, **options: Any) -> Self:
builder = cls()
builder._template_name = "components/core/layouts/grid"
builder._options["columns"] = columns
builder._options.update(options)
return builder
@classmethod
def hstack(
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
) -> Self:
builder = cls.row(**options)
for component in components:
builder.add_item(component)
return builder
@classmethod
def vstack(
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
) -> Self:
builder = cls.column(**options)
for component in components:
builder.add_item(component)
return builder
def add_item(
self,
component: "BaseBuilder | RenderableComponent",
metadata: dict[str, Any] | None = None,
) -> Self:
"""
向布局中添加一个组件项。
参数:
component: 一个 `BaseBuilder` 实例 (如 `TableBuilder()`) 或一个已构建的
`RenderableComponent` 数据模型。
metadata: (可选) 与此项目关联的元数据,可在布局模板中访问。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
component_data = (
component.data if isinstance(component, BaseBuilder) else component
)
self._data.children.append(
LayoutItem(component=component_data, metadata=metadata)
)
return self
def add_option(self, key: str, value: Any) -> Self:
"""
为布局模板添加一个自定义选项。
例如,`add_option("padding", "30px")` 会在模板的 `data.options`
字典中添加 `{"padding": "30px"}`。
参数:
key: 选项的键名。
value: 选项的值。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._options[key] = value
return self
def build(self) -> LayoutData:
"""
构建并返回 LayoutData 模型实例。
"""
if not self._template_name:
raise ValueError(
"必须通过工厂方法 (如 LayoutBuilder.column()) 初始化布局类型。"
)
self._data.options = self._options
self._data.layout_type = self._template_name.split("/")[-1]
return super().build()
-31
View File
@@ -1,31 +0,0 @@
from typing_extensions import Self
from ...models.core.base import RenderableComponent
from ...models.core.list import ListData, ListItem
from ..base import BaseBuilder
class ListBuilder(BaseBuilder[ListData]):
"""链式构建通用列表的辅助类。"""
def __init__(self, ordered: bool = False):
data_model = ListData(ordered=ordered)
super().__init__(data_model, template_name="components/core/list")
def add_item(self, component: "BaseBuilder | RenderableComponent") -> Self:
"""
向列表中添加一个项目。
参数:
component: 一个 Builder 实例或一个 RenderableComponent 数据模型。
"""
component_data = (
component.build() if isinstance(component, BaseBuilder) else component
)
self._data.items.append(ListItem(component=component_data))
return self
def ordered(self, is_ordered: bool = True) -> Self:
"""设置列表是否为有序列表(带数字编号)。"""
self._data.ordered = is_ordered
return self
-162
View File
@@ -1,162 +0,0 @@
from contextlib import AbstractContextManager
from pathlib import Path
from typing import Any
from ...models.core.markdown import (
CodeElement,
ComponentElement,
HeadingElement,
ImageElement,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
QuoteElement,
RawHtmlElement,
RenderableComponent,
TableElement,
TextElement,
)
from ..base import BaseBuilder
__all__ = ["MarkdownBuilder"]
class MarkdownBuilder(BaseBuilder[MarkdownData]):
"""链式构建Markdown图片的辅助类,支持上下文管理和组合。"""
def __init__(self):
data_model = MarkdownData(elements=[], width=800, css_path=None)
super().__init__(data_model, template_name="components/core/markdown")
self._parts: list[MarkdownElement] = []
self._width: int = 800
self._css_path: str | None = None
self._context_stack: list[QuoteElement | ListElement | ListItemElement] = []
def _append_element(self, element: MarkdownElement):
"""内部方法,根据上下文将元素添加到正确的位置。"""
if self._context_stack:
self._context_stack[-1].content.append(element)
else:
self._parts.append(element)
return self
def text(self, text: str) -> "MarkdownBuilder":
"""添加Markdown文本"""
self._append_element(TextElement(text=text))
return self
def head(self, text: str, level: int = 1) -> "MarkdownBuilder":
"""添加Markdown标题"""
self._append_element(HeadingElement(text=text, level=level))
return self
def image(self, content: str | Path, alt: str = "image") -> "MarkdownBuilder":
"""添加Markdown图片"""
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._append_element(ImageElement(src=src, alt=alt))
return self
def code(self, code: str, language: str = "") -> "MarkdownBuilder":
"""添加Markdown代码块"""
self._append_element(CodeElement(code=code, language=language))
return self
def table(
self,
headers: list[str],
rows: list[list[str]],
alignments: list[Any] | None = None,
) -> "MarkdownBuilder":
"""添加Markdown表格"""
self._append_element(
TableElement(headers=headers, rows=rows, alignments=alignments)
)
return self
def add_component(
self, component: "BaseBuilder | RenderableComponent"
) -> "MarkdownBuilder":
"""添加一个UI组件(如图表、卡片等)。"""
component_data = (
component.build() if isinstance(component, BaseBuilder) else component
)
self._append_element(ComponentElement(component=component_data))
return self
def add_builder(self, builder: "MarkdownBuilder") -> "MarkdownBuilder":
"""将另一个builder的内容组合进来。"""
if self._context_stack:
self._context_stack[-1].content.extend(builder._parts)
else:
self._parts.extend(builder._parts)
return self
def quote(self) -> AbstractContextManager["MarkdownBuilder"]:
"""创建一个引用块上下文。"""
return self._context_for(QuoteElement())
def list(self, ordered: bool = False) -> AbstractContextManager["MarkdownBuilder"]:
"""创建一个列表上下文。"""
return self._context_for(ListElement(ordered=ordered))
def list_item(self) -> AbstractContextManager["MarkdownBuilder"]:
"""在列表上下文中创建一个列表项。"""
if not self._context_stack or not isinstance(
self._context_stack[-1], ListElement
):
raise TypeError("list_item() 只能在 list() 上下文中使用。")
return self._context_for(ListItemElement())
class _ContextManager:
def __init__(
self,
builder: "MarkdownBuilder",
element: QuoteElement | ListElement | ListItemElement,
):
self.builder = builder
self.element = element
def __enter__(self):
self.builder._context_stack.append(self.element)
return self.builder
def __exit__(self, exc_type, exc_val, exc_tb):
del exc_type, exc_val, exc_tb
self.builder._context_stack.pop()
def _context_for(
self, element: QuoteElement | ListElement | ListItemElement
) -> AbstractContextManager["MarkdownBuilder"]:
self._append_element(element)
return self._ContextManager(self, element)
def set_width(self, width: int) -> "MarkdownBuilder":
"""设置图片宽度"""
self._width = width
return self
def set_css_path(self, css_path: str) -> "MarkdownBuilder":
"""设置CSS样式路径"""
self._css_path = css_path
return self
def add_divider(self) -> "MarkdownBuilder":
"""添加一条标准的 Markdown 分割线。"""
self._append_element(RawHtmlElement(html="---"))
return self
def build(self) -> MarkdownData:
"""
构建并返回 MarkdownData 模型实例。
"""
self._data.elements = self._parts
self._data.width = self._width
self._data.css_path = self._css_path
return super().build()
-120
View File
@@ -1,120 +0,0 @@
import builtins
from pathlib import Path
from ...models.core.base import RenderableComponent
from ...models.core.notebook import NotebookData, NotebookElement
from ..base import BaseBuilder
__all__ = ["NotebookBuilder"]
class NotebookBuilder(BaseBuilder[NotebookData]):
"""
一个用于链式构建 Notebook 页面的辅助类。
"""
def __init__(self, data: list[NotebookElement] | None = None):
elements = data if data is not None else []
data_model = NotebookData(elements=elements)
super().__init__(data_model, template_name="components/core/notebook")
self._elements = elements
def text(self, text: str) -> "NotebookBuilder":
"""添加Notebook文本"""
self._elements.append(NotebookElement(type="paragraph", text=text))
return self
def head(self, text: str, level: int = 1) -> "NotebookBuilder":
"""添加Notebook标题"""
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,
caption: str | None = None,
) -> "NotebookBuilder":
"""添加Notebook图片"""
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]) -> "NotebookBuilder":
"""添加Notebook引用文本"""
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") -> "NotebookBuilder":
"""添加Notebook代码块"""
self._elements.append(
NotebookElement(type="code", code=code, language=language)
)
return self
def list(self, items: list[str], ordered: bool = False) -> "NotebookBuilder":
"""添加Notebook列表"""
self._elements.append(NotebookElement(type="list", data=items, ordered=ordered))
return self
def add_divider(self, **kwargs) -> "NotebookBuilder":
"""
添加分隔线。
:param kwargs: Divider组件的可选参数, 如 margin, color, style, thickness。
"""
from ...models.components import Divider
self.add_component(Divider(**kwargs))
return self
def add_component(
self, component: "RenderableComponent | BaseBuilder"
) -> "NotebookBuilder":
"""
向 Notebook 中添加一个可渲染的自定义组件。
"""
component_data = (
component.data if isinstance(component, BaseBuilder) else component
)
if not isinstance(component_data, RenderableComponent):
raise TypeError(
f"add_component 只能接受 RenderableComponent 或其 Builder,"
f"但收到了 {type(component)}"
)
self._elements.append(
NotebookElement(type="component", component=component_data)
)
return self
def add_texts(self, texts: builtins.list[str]) -> "NotebookBuilder":
"""批量添加多个文本段落"""
for text in texts:
self.text(text)
return self
def add_quotes(self, quotes: builtins.list[str]) -> "NotebookBuilder":
"""批量添加引用"""
for quote in quotes:
self.quote(quote)
return self
def build(self) -> NotebookData:
"""
构建并返回 NotebookData 模型实例。
"""
self._data.elements = self._elements
return super().build()
-105
View File
@@ -1,105 +0,0 @@
from pathlib import Path
from typing import Any, Literal
from ...models.core.table import (
BaseCell,
ImageCell,
TableCell,
TableData,
TextCell,
)
from ..base import BaseBuilder
__all__ = ["TableBuilder"]
class TableBuilder(BaseBuilder[TableData]):
"""链式构建通用表格的辅助类"""
def __init__(self, title: str, tip: str | None = None):
data_model = TableData(title=title, tip=tip, headers=[], rows=[])
super().__init__(data_model, template_name="components/core/table")
def _normalize_cell(self, cell_data: Any) -> TableCell:
"""内部辅助方法,将各种原生数据类型转换为TableCell模型。"""
if isinstance(cell_data, BaseCell):
return cell_data # type: ignore
if isinstance(cell_data, str | int | float):
return TextCell(content=str(cell_data))
if isinstance(cell_data, Path):
return ImageCell(src=cell_data.resolve().as_uri())
if isinstance(cell_data, tuple) and len(cell_data) == 3:
if (
isinstance(cell_data[0], Path)
and isinstance(cell_data[1], int)
and isinstance(cell_data[2], int)
):
return ImageCell(
src=cell_data[0].resolve().as_uri(),
width=cell_data[1],
height=cell_data[2],
)
return TextCell(content="")
def set_headers(self, headers: list[str]) -> "TableBuilder":
"""
设置表格的表头。
参数:
headers: 一个包含表头文本的字符串列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
self._data.headers = headers
return self
def set_column_alignments(
self, alignments: list[Literal["left", "center", "right"]]
) -> "TableBuilder":
"""
设置表格每列的文本对齐方式。
参数:
alignments: 一个包含 'left', 'center', 'right' 的对齐方式列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
self._data.column_alignments = alignments
return self
def set_column_widths(self, widths: list[str | int]) -> "TableBuilder":
"""设置每列的宽度"""
self._data.column_widths = widths
return self
def add_row(self, row: list[TableCell]) -> "TableBuilder":
"""
向表格中添加一行数据。
参数:
row: 一个包含单元格数据的列表。单元格可以是字符串、数字或
`TextCell`, `ImageCell` 等模型实例。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
normalized_row = [self._normalize_cell(cell) for cell in row]
self._data.rows.append(normalized_row)
return self
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
"""
向表格中批量添加多行数据, 并自动转换原生类型。
参数:
rows: 一个包含多行数据的列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
for row in rows:
self.add_row(row)
return self
-62
View File
@@ -1,62 +0,0 @@
from typing import Literal
from typing_extensions import Self
from ...models.core.text import TextData, TextSpan
from ..base import BaseBuilder
class TextBuilder(BaseBuilder[TextData]):
"""链式构建轻量级富文本组件的辅助类"""
def __init__(self, text: str = ""):
data_model = TextData(spans=[], align="left")
super().__init__(data_model, template_name="components/core/text")
if text:
self.add_span(text)
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
"""设置整个文本块的对齐方式"""
self._data.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:
"""
添加一个带有样式的文本片段。
参数:
text: 文本内容。
bold: 是否加粗。
italic: 是否斜体。
underline: 是否有下划线。
strikethrough: 是否有删除线。
code: 是否渲染为代码样式。
color: 文本颜色 (e.g., '#ff0000', 'red')。
font_size: 字体大小 (e.g., 16, '1.2em', '12px')。
font_family: 字体族。
"""
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._data.spans.append(span)
return self
-12
View File
@@ -1,12 +0,0 @@
"""
预设构建器模块
包含预定义的UI组件构建器
"""
from .plugin_help_page import PluginHelpPageBuilder
from .plugin_menu import PluginMenuBuilder
__all__ = [
"PluginHelpPageBuilder",
"PluginMenuBuilder",
]
@@ -1,27 +0,0 @@
from ...models.presets.plugin_help_page import (
HelpCategory,
PluginHelpPageData,
)
from ..base import BaseBuilder
class PluginHelpPageBuilder(BaseBuilder[PluginHelpPageData]):
"""链式构建插件帮助页面的辅助类"""
def __init__(self, bot_nickname: str, page_title: str):
self._data = PluginHelpPageData(
bot_nickname=bot_nickname, page_title=page_title, categories=[]
)
super().__init__(self._data, template_name="pages/core/plugin_help_page")
def add_category(self, category: HelpCategory) -> "PluginHelpPageBuilder":
"""添加一个帮助分类"""
self._data.categories.append(category)
return self
def add_categories(self, categories: list[HelpCategory]) -> "PluginHelpPageBuilder":
"""批量添加帮助分类"""
for category in categories:
self.add_category(category)
return self
@@ -1,36 +0,0 @@
from ...models.presets.plugin_menu import (
PluginMenuCategory,
PluginMenuData,
)
from ..base import BaseBuilder
__all__ = ["PluginMenuBuilder"]
class PluginMenuBuilder(BaseBuilder[PluginMenuData]):
"""链式构建插件菜单的辅助类"""
def __init__(self, bot_name: str, bot_avatar_url: str, is_detail: bool = False):
self._data = PluginMenuData(
bot_name=bot_name,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
plugin_count=0,
active_count=0,
categories=[],
)
super().__init__(self._data, template_name="pages/core/plugin_menu")
def add_category(self, category: PluginMenuCategory) -> "PluginMenuBuilder":
self._data.categories.append(category)
self._data.plugin_count += len(category.items)
self._data.active_count += sum(1 for item in category.items if item.status)
return self
def add_categories(
self, categories: list[PluginMenuCategory]
) -> "PluginMenuBuilder":
for category in categories:
self.add_category(category)
return self
+144 -1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Literal
from typing_extensions import Self
import uuid
from pydantic import BaseModel, Field
@@ -94,7 +95,10 @@ class BaseChartData(RenderableComponent, ABC):
class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型"""
template_path: str = Field(..., exclude=True, description="图表组件的模板路径")
class Config:
populate_by_name = True
template_path: str = Field(..., exclude=True, description="图表组件的模板路径") # type: ignore
"""图表组件的模板路径"""
title_model: EChartsTitle | None = Field(
None, alias="title", description="标题组件"
@@ -160,3 +164,142 @@ class EChartsData(BaseChartData):
@property
def template_name(self) -> str:
return self.template_path
def set_title(
self, text: str, left: Literal["left", "center", "right"] = "center"
) -> Self:
self.title_model = EChartsTitle(text=text, left=left)
return self
def set_grid(
self,
left: str | None = None,
right: str | None = None,
top: str | None = None,
bottom: str | None = None,
containLabel: bool = True,
) -> Self:
self.grid_model = EChartsGrid(
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
)
return self
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
self.tooltip_model = EChartsTooltip(trigger=trigger)
return self
def set_x_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self.x_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
def set_y_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self.y_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
def add_series(
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
) -> Self:
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
self.series_models.append(series)
return self
def set_legend(
self,
data: list[str],
orient: Literal["horizontal", "vertical"] = "horizontal",
left: str = "auto",
) -> Self:
self.legend_model = {"data": data, "orient": orient, "left": left}
return self
def set_option(self, key: str, value: Any) -> Self:
self.raw_options[key] = value
return self
def set_background_image(self, image_name: str) -> Self:
self.background_image = image_name
return self
@classmethod
def bar_chart(
cls,
title: str,
items: list[tuple[str, int | float]],
direction: Literal["horizontal", "vertical"] = "horizontal",
background_image: str | None = None,
) -> "EChartsData":
"""便捷创建一个柱状图"""
categories = [item[0] for item in items]
values = [item[1] for item in items]
if direction == "horizontal":
x_axis = EChartsAxis(type="value")
y_axis = EChartsAxis(type="category", data=categories)
else:
x_axis = EChartsAxis(type="category", data=categories)
y_axis = EChartsAxis(type="value")
return cls(
template_path="components/charts/bar_chart",
title=EChartsTitle(text=title),
grid=None,
xAxis=x_axis,
yAxis=y_axis,
tooltip=EChartsTooltip(trigger="item"),
series=[EChartsSeries(type="bar", data=values)],
background_image=background_image,
)
@classmethod
def pie_chart(
cls, title: str, items: list[tuple[str, int | float]]
) -> "EChartsData":
"""便捷创建一个饼图"""
data = [{"name": name, "value": value} for name, value in items]
legend_data = [item[0] for item in items]
return cls(
template_path="components/charts/pie_chart",
title=EChartsTitle(text=title),
grid=None,
tooltip=EChartsTooltip(trigger="item"),
xAxis=None,
yAxis=None,
legend={"data": legend_data, "orient": "horizontal", "left": "auto"},
series=[EChartsSeries(type="pie", data=data, name=title)],
background_image=None,
)
@classmethod
def line_chart(
cls, title: str, categories: list[str], series: list[dict[str, Any]]
) -> "EChartsData":
"""便捷创建一个折线图"""
series_models = [
EChartsSeries(
type="line",
name=s.get("name", ""),
data=s.get("data", []),
smooth=s.get("smooth", False),
)
for s in series
]
return cls(
template_path="components/charts/line_chart",
title=EChartsTitle(text=title),
grid=None,
xAxis=EChartsAxis(type="category", data=categories),
yAxis=EChartsAxis(type="value"),
tooltip=EChartsTooltip(trigger="axis"),
series=series_models,
background_image=None,
)
+5 -12
View File
@@ -1,18 +1,11 @@
"""
组件模型模块
包含各种UI组件的数据模型
"""
from .alert import Alert
from .badge import Badge
from .divider import Divider, Rectangle
from .kpi_card import KpiCard
from .progress_bar import ProgressBar
from .timeline import Timeline, TimelineItem
from .user_info_block import UserInfoBlock
from .data import KpiCard, Timeline, TimelineItem
from .display import Avatar, AvatarGroup, Divider, Rectangle, UserInfoBlock
from .feedback import Alert, Badge, ProgressBar
__all__ = [
"Alert",
"Avatar",
"AvatarGroup",
"Badge",
"Divider",
"KpiCard",
-27
View File
@@ -1,27 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Alert"]
class Alert(RenderableComponent):
"""一个带样式的提示框组件,用于显示重要信息。"""
component_type: Literal["alert"] = "alert"
type: Literal["info", "success", "warning", "error"] = Field(
default="info", description="提示框的类型,决定了颜色和图标"
)
"""提示框的类型,决定了颜色和图标"""
title: str = Field(..., description="提示框的标题")
"""提示框的标题"""
content: str = Field(..., description="提示框的主要内容")
"""提示框的主要内容"""
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
"""是否显示与类型匹配的图标"""
@property
def template_name(self) -> str:
return "components/widgets/alert"
-41
View File
@@ -1,41 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Avatar", "AvatarGroup"]
class Avatar(RenderableComponent):
"""单个头像组件。"""
component_type: Literal["avatar"] = "avatar"
src: str = Field(..., description="头像的URL或Base64数据URI")
"""头像的URL或Base64数据URI"""
shape: Literal["circle", "square"] = Field("circle", description="头像形状")
"""头像形状"""
size: int = Field(50, description="头像尺寸(像素)")
"""头像尺寸(像素)"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class AvatarGroup(RenderableComponent):
"""一组堆叠的头像组件。"""
component_type: Literal["avatar_group"] = "avatar_group"
avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
"""头像列表"""
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
"""头像间的间距(负数表示重叠)"""
max_count: int | None = Field(
None, description="最多显示的头像数量,超出部分会显示为'+N'"
)
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
-24
View File
@@ -1,24 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Badge"]
class Badge(RenderableComponent):
"""一个简单的徽章组件,用于显示状态或标签。"""
component_type: Literal["badge"] = "badge"
text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info",
description="预设的颜色方案",
)
"""预设的颜色方案"""
@property
def template_name(self) -> str:
return "components/widgets/badge"
@@ -1,16 +1,17 @@
from typing import Any, Literal
from pydantic import Field
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = ["KpiCard"]
__all__ = ["KpiCard", "Timeline", "TimelineItem"]
class KpiCard(RenderableComponent):
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
component_type: Literal["kpi_card"] = "kpi_card"
"""组件类型"""
label: str = Field(..., description="指标的标签或名称")
"""指标的标签或名称"""
value: Any = Field(..., description="指标的主要数值")
@@ -33,3 +34,33 @@ class KpiCard(RenderableComponent):
@property
def template_name(self) -> str:
return "components/widgets/kpi_card"
class TimelineItem(BaseModel):
"""时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
"""显示在时间点旁边的时间或标签"""
title: str = Field(..., description="事件的标题")
"""事件的标题"""
content: str = Field(..., description="事件的详细描述")
"""事件的详细描述"""
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
"""可选的自定义图标SVG路径"""
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
"""可选的自定义颜色,覆盖默认"""
class Timeline(RenderableComponent):
"""一个垂直时间轴组件,用于按顺序展示事件。"""
component_type: Literal["timeline"] = "timeline"
"""组件类型"""
items: list[TimelineItem] = Field(
default_factory=list, description="时间轴项目列表"
)
"""时间轴项目列表"""
@property
def template_name(self) -> str:
return "components/widgets/timeline"
+102
View File
@@ -0,0 +1,102 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Avatar", "AvatarGroup", "Divider", "Rectangle", "UserInfoBlock"]
class Avatar(RenderableComponent):
"""单个头像组件。"""
component_type: Literal["avatar"] = "avatar"
"""组件类型"""
src: str = Field(..., description="头像的URL或Base64数据URI")
"""头像的URL或Base64数据URI"""
shape: Literal["circle", "square"] = Field("circle", description="头像形状")
"""头像形状"""
size: int = Field(50, description="头像尺寸(像素)")
"""头像尺寸(像素)"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class AvatarGroup(RenderableComponent):
"""一组堆叠的头像组件。"""
component_type: Literal["avatar_group"] = "avatar_group"
"""组件类型"""
avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
"""头像列表"""
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
"""头像间的间距(负数表示重叠)"""
max_count: int | None = Field(
None, description="最多显示的头像数量,超出部分会显示为'+N'"
)
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class Divider(RenderableComponent):
"""一个简单的分割线组件。"""
component_type: Literal["divider"] = "divider"
"""组件类型"""
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property
def template_name(self) -> str:
return "components/widgets/divider"
class Rectangle(RenderableComponent):
"""一个矩形背景块组件。"""
component_type: Literal["rectangle"] = "rectangle"
"""组件类型"""
height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property
def template_name(self) -> str:
return "components/widgets/rectangle"
class UserInfoBlock(RenderableComponent):
"""一个带头像、名称和副标题的用户信息块组件。"""
component_type: Literal["user_info_block"] = "user_info_block"
"""组件类型"""
avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)"
)
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property
def template_name(self) -> str:
return "components/widgets/user_info_block"
-43
View File
@@ -1,43 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Divider", "Rectangle"]
class Divider(RenderableComponent):
"""一个简单的分割线组件。"""
component_type: Literal["divider"] = "divider"
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property
def template_name(self) -> str:
return "components/widgets/divider"
class Rectangle(RenderableComponent):
"""一个矩形背景块组件。"""
component_type: Literal["rectangle"] = "rectangle"
height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property
def template_name(self) -> str:
return "components/widgets/rectangle"
+70
View File
@@ -0,0 +1,70 @@
from typing import Literal
from pydantic import Field
from ...registry import component
from ..core.base import RenderableComponent
__all__ = ["Alert", "Badge", "ProgressBar"]
@component(name="alert", namespace="core")
class Alert(RenderableComponent):
"""一个带样式的提示框组件,用于显示重要信息。"""
component_type: Literal["alert"] = "alert"
"""组件类型"""
type: Literal["info", "success", "warning", "error"] = Field(
default="info", description="提示框的类型,决定了颜色和图标"
)
"""提示框的类型,决定了颜色和图标"""
title: str = Field(..., description="提示框的标题")
"""提示框的标题"""
content: str = Field(..., description="提示框的主要内容")
"""提示框的主要内容"""
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
"""是否显示与类型匹配的图标"""
@property
def template_name(self) -> str:
return "components/widgets/alert"
class Badge(RenderableComponent):
"""一个简单的徽章组件,用于显示状态或标签。"""
component_type: Literal["badge"] = "badge"
"""组件类型"""
text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info",
description="预设的颜色方案",
)
"""预设的颜色方案"""
@property
def template_name(self) -> str:
return "components/widgets/badge"
class ProgressBar(RenderableComponent):
"""一个进度条组件。"""
component_type: Literal["progress_bar"] = "progress_bar"
"""组件类型"""
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary",
description="预设的颜色方案",
)
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property
def template_name(self) -> str:
return "components/widgets/progress_bar"
@@ -1,28 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["ProgressBar"]
class ProgressBar(RenderableComponent):
"""一个进度条组件。"""
component_type: Literal["progress_bar"] = "progress_bar"
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary",
description="预设的颜色方案",
)
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property
def template_name(self) -> str:
return "components/widgets/progress_bar"
-36
View File
@@ -1,36 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = ["Timeline", "TimelineItem"]
class TimelineItem(BaseModel):
"""时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
"""显示在时间点旁边的时间或标签"""
title: str = Field(..., description="事件的标题")
"""事件的标题"""
content: str = Field(..., description="事件的详细描述")
"""事件的详细描述"""
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
"""可选的自定义图标SVG路径"""
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
"""可选的自定义颜色,覆盖默认"""
class Timeline(RenderableComponent):
"""一个垂直时间轴组件,用于按顺序展示事件。"""
component_type: Literal["timeline"] = "timeline"
items: list[TimelineItem] = Field(
default_factory=list, description="时间轴项目列表"
)
"""时间轴项目列表"""
@property
def template_name(self) -> str:
return "components/widgets/timeline"
@@ -1,27 +0,0 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["UserInfoBlock"]
class UserInfoBlock(RenderableComponent):
"""一个带头像、名称和副标题的用户信息块组件。"""
component_type: Literal["user_info_block"] = "user_info_block"
avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)"
)
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property
def template_name(self) -> str:
return "components/widgets/user_info_block"
+24 -20
View File
@@ -1,45 +1,48 @@
"""
核心模型模块
包含基础的数据模型类
"""
from .base import RenderableComponent
from .card import CardData
from .details import DetailsData, DetailsItem
from .layout import LayoutData, LayoutItem
from .list import ListData, ListItem
from .markdown import (
from .containers import (
CardData,
LayoutData,
LayoutItem,
ListData,
ListItem,
NotebookData,
NotebookElement,
TemplateComponent,
)
from .content import (
BaseCell,
CodeElement,
ComponentCell,
ComponentElement,
DetailsData,
DetailsItem,
HeadingElement,
ImageCell,
ImageElement,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
ProgressBarCell,
QuoteElement,
RawHtmlElement,
TableElement,
TextElement,
)
from .notebook import NotebookData, NotebookElement
from .table import (
BaseCell,
ComponentCell,
ImageCell,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TableElement,
TextCell,
TextData,
TextElement,
TextSpan,
)
from .template import TemplateComponent
from .text import TextData, TextSpan
__all__ = [
"BaseCell",
"CardData",
"CodeElement",
"ComponentCell",
"ComponentElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
@@ -55,6 +58,7 @@ __all__ = [
"MarkdownElement",
"NotebookData",
"NotebookElement",
"ProgressBarCell",
"QuoteElement",
"RawHtmlElement",
"RenderableComponent",
+118 -23
View File
@@ -1,22 +1,45 @@
from abc import ABC, abstractmethod
from abc import ABC
from collections.abc import Awaitable, Iterable
from typing import Any
from typing_extensions import Self
from pydantic import BaseModel
from pydantic import VERSION as PYDANTIC_VERSION
from pydantic import BaseModel, Field
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.services.renderer.types import Renderable
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
__all__ = ["ContainerComponent", "RenderableComponent"]
def _iter_renderables(obj: Any) -> Iterable["Renderable"]:
"""
递归遍历对象,查找所有 Renderable 实例。
支持列表、字典以及嵌套的 Pydantic 模型。
"""
if isinstance(obj, Renderable):
yield obj
elif isinstance(obj, list | tuple):
for item in obj:
yield from _iter_renderables(item)
elif isinstance(obj, dict):
for value in obj.values():
yield from _iter_renderables(value)
elif isinstance(obj, BaseModel):
if PYDANTIC_VERSION.startswith("1"):
fields = obj.__fields__
else:
fields = obj.model_fields # type: ignore
for field_name in fields:
value = getattr(obj, field_name)
yield from _iter_renderables(value)
class RenderableComponent(BaseModel, Renderable):
"""
所有可渲染UI组件的数据模型基类。
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
协议,确保其能够被 `RendererService` 正确处理。
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
提供通用的样式属性(如内联样式、CSS类、变体)和链式调用方法。
"""
_is_standalone_template: bool = False
@@ -29,24 +52,103 @@ class RenderableComponent(BaseModel, Renderable):
"""应用于组件根元素的额外CSS类名列表"""
variant: str | None = None
"""组件的变体/皮肤名称"""
style_name: str | None = None
"""组件的样式名称"""
is_page: bool = False
"""标记此组件是否为完整页面(自带html/body), 渲染时将跳过通用包装器"""
template_path: str | None = Field(
default=None, description="动态覆盖的模板路径", exclude=True
)
"""动态覆盖的模板路径,若设置则优先于 template_name 属性"""
@property
def template_name(self) -> str:
"""
返回用于渲染此组件的Jinja2模板的路径。
这是一个抽象属性,所有子类都必须覆盖它。
返回用于渲染此组件的 Jinja2 模板路径。
"""
raise NotImplementedError(
"Subclasses must implement the 'template_name' property."
)
return ""
def with_style(self, style_name: str) -> Self:
"""
设置组件样式名称。
参数:
style_name: 样式名称,通常对应主题中的一组CSS定义
"""
self.style_name = style_name
return self
def with_variant(self, variant: str) -> Self:
"""
设置组件变体(皮肤)。
参数:
variant: 变体名称,用于加载不同的模板或样式集
"""
self.variant = variant
return self
def with_classes(self, *classes: str) -> Self:
"""
添加 CSS 类名。
参数:
*classes: 一个或多个 CSS 类名
"""
if self.extra_classes is None:
self.extra_classes = []
self.extra_classes.extend(classes)
return self
def with_inline_style(self, style: dict[str, str]) -> Self:
"""
设置内联 CSS 样式。
参数:
style: 样式键值对字典 (e.g. {'color': 'red'})
"""
if self.inline_style is None:
self.inline_style = {}
self.inline_style.update(style)
return self
def with_component_css(self, css: str) -> Self:
"""
注入自定义 CSS 代码块。
参数:
css: CSS 代码字符串
"""
self.component_css = css
return self
def update(self, **kwargs) -> Self:
"""批量更新组件属性。"""
for k, v in kwargs.items():
if hasattr(self, k):
setattr(self, k, v)
return self
def build(self) -> Self:
"""
返回组件自身(兼容 Builder 模式调用)。
"""
return self
async def prepare(self) -> None:
"""[可选] 生命周期钩子,默认无操作。"""
"""[生命周期] 渲染前的异步准备步骤。"""
pass
def get_children(self) -> Iterable["RenderableComponent"]:
"""默认实现:非容器组件没有子组件。"""
return []
def get_children(self) -> Iterable["Renderable"]:
"""获取所有子组件的迭代器。"""
if PYDANTIC_VERSION.startswith("1"):
fields = self.__fields__
else:
fields = self.model_fields # type: ignore
for field_name in fields:
value = getattr(self, field_name)
yield from _iter_renderables(value)
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
@@ -78,13 +180,6 @@ class ContainerComponent(RenderableComponent, ABC):
一个为容器类组件设计的抽象基类,封装了预渲染子组件的通用逻辑。
"""
@abstractmethod
def get_children(self) -> Iterable[RenderableComponent]:
"""
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
"""
raise NotImplementedError
def get_required_scripts(self) -> list[str]:
"""聚合所有子组件的脚本依赖。"""
scripts = set(super().get_required_scripts())
-27
View File
@@ -1,27 +0,0 @@
from collections.abc import Iterable
from .base import ContainerComponent, RenderableComponent
class CardData(ContainerComponent):
"""通用卡片的数据模型,可以包含头部、内容和尾部"""
header: RenderableComponent | None = None
"""卡片的头部内容组件"""
content: RenderableComponent
"""卡片的主要内容组件"""
footer: RenderableComponent | None = None
"""卡片的尾部内容组件"""
@property
def template_name(self) -> str:
return "components/core/card"
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS收集器能够遍历卡片的子组件"""
if self.header:
yield self.header
if self.content:
yield self.content
if self.footer:
yield self.footer
+294
View File
@@ -0,0 +1,294 @@
import builtins
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Literal
from typing_extensions import Self
from pydantic import BaseModel, Field
from ...registry import component
from .base import ContainerComponent, Renderable, RenderableComponent
__all__ = [
"CardData",
"LayoutData",
"LayoutItem",
"ListData",
"ListItem",
"NotebookData",
"NotebookElement",
"TemplateComponent",
]
class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件"""
_is_standalone_template: bool = True
template_path: str | Path = Field(..., description="指向HTML模板文件的路径") # type: ignore
"""指向HTML模板文件的路径"""
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
"""传递给模板的上下文数据字典"""
@property
def template_name(self) -> str:
if isinstance(self.template_path, Path):
return self.template_path.as_posix()
return str(self.template_path)
def get_render_data(self) -> dict[str, Any]:
return self.data
def __getattr__(self, name: str) -> Any:
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
@component(name="card", namespace="core")
class CardData(ContainerComponent):
"""通用卡片的数据模型,可以包含头部、内容和尾部"""
header: RenderableComponent | None = None
content: RenderableComponent
footer: RenderableComponent | None = None
@property
def template_name(self) -> str:
return "components/core/card"
def get_children(self) -> Iterable["Renderable"]:
if self.header:
yield self.header
if self.content:
yield self.content
if self.footer:
yield self.footer
def set_header(self, header: "RenderableComponent") -> Self:
self.header = header
return self
def set_footer(self, footer: "RenderableComponent") -> Self:
self.footer = footer
return self
class LayoutItem(BaseModel):
"""布局中的单个项目"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent):
"""布局构建器的数据模型"""
style_name: str | None = None
layout_type: str = "column"
children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表"
)
"""要布局的项目列表"""
options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项"
)
"""传递给模板的选项"""
@property
def template_name(self) -> str:
return f"components/core/layouts/{self.layout_type}"
@classmethod
def column(
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
) -> Self:
options.update({"gap": gap, "align_items": align_items})
return cls(layout_type="column", options=options)
@classmethod
def row(
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
) -> Self:
options.update({"gap": gap, "align_items": align_items})
return cls(layout_type="row", options=options)
@classmethod
def grid(cls, columns: int = 2, **options: Any) -> Self:
options.update({"columns": columns})
return cls(layout_type="grid", options=options)
def add_item(
self,
component: "RenderableComponent",
metadata: dict[str, Any] | None = None,
) -> Self:
self.children.append(LayoutItem(component=component, metadata=metadata))
return self
def add_option(self, key: str, value: Any) -> Self:
self.options[key] = value
return self
def get_children(self) -> Iterable["Renderable"]:
for item in self.children:
if item.component:
yield item.component
def get_extra_css(self, context: Any) -> str:
all_css = []
if self.component_css:
all_css.append(self.component_css)
for item in self.children:
if (
item.component
and hasattr(item.component, "component_css")
and item.component.component_css
):
all_css.append(item.component.component_css)
return "\n".join(all_css)
class ListItem(BaseModel):
"""列表中的单个项目"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
class ListData(ContainerComponent):
"""通用列表的数据模型"""
component_type: Literal["list"] = "list"
"""组件类型"""
items: list[ListItem] = Field(default_factory=list, description="列表项目")
"""列表项目"""
ordered: bool = Field(default=False, description="是否为有序列表")
"""是否为有序列表"""
@property
def template_name(self) -> str:
return "components/core/list"
def get_children(self) -> Iterable["Renderable"]:
for item in self.items:
if item.component:
yield item.component
def add_item(self, component: "RenderableComponent") -> Self:
self.items.append(ListItem(component=component))
return self
def set_ordered(self, ordered: bool = True) -> Self:
self.ordered = ordered
return self
class NotebookElement(BaseModel):
"""一个 Notebook 页面中的单个元素"""
type: Literal[
"heading",
"paragraph",
"image",
"blockquote",
"code",
"list",
"divider",
"component",
]
"""元素类型"""
text: str | None = None
"""文本内容"""
level: int | None = None
"""标题级别"""
src: str | None = None
"""图片链接"""
caption: str | None = None
"""图片说明"""
code: str | None = None
"""代码块内容"""
language: str | None = None
"""代码语言"""
data: list[str] | None = None
"""列表数据"""
ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None
"""可渲染组件"""
class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型"""
style_name: str | None = None
elements: list[NotebookElement]
@property
def template_name(self) -> str:
return "components/core/notebook"
def get_children(self) -> Iterable["Renderable"]:
for element in self.elements:
if element.component:
yield element.component
def text(self, text: str) -> Self:
self.elements.append(NotebookElement(type="paragraph", text=text))
return self
def head(self, text: str, level: int = 1) -> Self:
if not 1 <= level <= 4:
raise ValueError("标题级别必须在1-4之间")
self.elements.append(NotebookElement(type="heading", text=text, level=level))
return self
def image(self, content: str | Path, caption: str | None = None) -> Self:
src = ""
if isinstance(content, Path):
src = content.absolute().as_uri()
elif content.startswith("base64"):
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
else:
src = content
self.elements.append(NotebookElement(type="image", src=src, caption=caption))
return self
def quote(self, text: str | list[str]) -> Self:
if isinstance(text, str):
self.elements.append(NotebookElement(type="blockquote", text=text))
elif isinstance(text, list):
for t in text:
self.elements.append(NotebookElement(type="blockquote", text=t))
return self
def code(self, code: str, language: str = "python") -> Self:
self.elements.append(NotebookElement(type="code", code=code, language=language))
return self
def list(self, items: list[str], ordered: bool = False) -> Self:
self.elements.append(NotebookElement(type="list", data=items, ordered=ordered))
return self
def add_divider(self) -> Self:
self.elements.append(NotebookElement(type="divider"))
return self
def add_component(self, component: "RenderableComponent") -> Self:
self.elements.append(NotebookElement(type="component", component=component))
return self
def add_texts(self, texts: builtins.list[str]) -> Self:
for t in texts:
self.text(t)
return self
def add_quotes(self, quotes: builtins.list[str]) -> Self:
for q in quotes:
self.quote(q)
return self
+510
View File
@@ -0,0 +1,510 @@
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from pathlib import Path
from typing import Any, Literal
from typing_extensions import Self
import aiofiles
from anyio import Path as AsyncPath
from pydantic import BaseModel, Field, PrivateAttr
from zhenxun.services.log import logger
from zhenxun.ui.models.components.feedback import ProgressBar
from .base import ContainerComponent, RenderableComponent
__all__ = [
"BaseCell",
"CodeElement",
"ComponentCell",
"ComponentElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
"ImageCell",
"ImageElement",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"ProgressBarCell",
"QuoteElement",
"RawHtmlElement",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TableElement",
"TextCell",
"TextData",
"TextElement",
"TextSpan",
]
class TextSpan(BaseModel):
"""单个富文本片段的数据模型"""
text: str
"""文本内容"""
bold: bool = False
"""是否加粗"""
italic: bool = False
"""是否斜体"""
underline: bool = False
"""是否下划线"""
strikethrough: bool = False
"""是否删除线"""
code: bool = False
"""是否为等宽代码样式"""
color: str | None = None
"""文本颜色 (CSS color)"""
font_size: str | None = None
"""字体大小 (CSS font-size)"""
font_family: str | None = None
"""字体族 (CSS font-family)"""
class TextData(RenderableComponent):
"""轻量级富文本组件的数据模型"""
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
align: Literal["left", "right", "center"] = Field(
"left", description="整体文本对齐方式"
)
"""整体文本对齐方式"""
@property
def template_name(self) -> str:
return "components/core/text"
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
self.align = align
return self
def add_span(
self,
text: str,
*,
bold: bool = False,
italic: bool = False,
underline: bool = False,
strikethrough: bool = False,
code: bool = False,
color: str | None = None,
font_size: str | int | None = None,
font_family: str | None = None,
) -> Self:
font_size_str = f"{font_size}px" if isinstance(font_size, int) else font_size
span = TextSpan(
text=text,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
code=code,
color=color,
font_size=font_size_str,
font_family=font_family,
)
self.spans.append(span)
return self
class DetailsItem(BaseModel):
label: str = Field(..., description="项目的标签/键")
"""项目的标签/键"""
value: Any = Field(..., description="项目的值")
"""项目的值"""
class DetailsData(RenderableComponent):
"""描述列表(键值对)的数据模型"""
title: str | None = Field(None, description="列表的可选标题")
"""列表的可选标题"""
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
"""键值对项目列表"""
@property
def template_name(self) -> str:
return "components/core/details"
def add_item(self, label: str, value: Any) -> Self:
self.items.append(DetailsItem(label=label, value=str(value)))
return self
class MarkdownElement(BaseModel, ABC):
@abstractmethod
def to_markdown(self) -> str:
pass
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
"""元素类型"""
text: str
"""文本内容"""
def to_markdown(self) -> str:
return self.text
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
"""元素类型"""
text: str
"""标题文本"""
level: int = Field(..., ge=1, le=6)
"""标题级别 (1-6)"""
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
alt: str = "image"
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
language: str = ""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
rows: list[list[str]]
alignments: list[Literal["left", "center", "right"]] | None = None
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
if self.alignments:
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
separator_row = (
"| "
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
+ " |"
)
else:
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(default_factory=list)
class QuoteElement(ContainerElement):
type: Literal["quote"] = "quote"
def to_markdown(self) -> str:
inner_md = "\n".join(part.to_markdown() for part in self.content)
return "\n".join([f"> {line}" for line in inner_md.split("\n")])
class ListItemElement(ContainerElement):
def to_markdown(self) -> str:
return "\n".join(part.to_markdown() for part in self.content)
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
def to_markdown(self) -> str:
lines = []
for i, item in enumerate(self.content):
if isinstance(item, ListItemElement):
prefix = f"{i + 1}." if self.ordered else "*"
item_content = item.to_markdown()
lines.append(f"{prefix} {item_content}")
return "\n".join(lines)
class ComponentElement(MarkdownElement):
type: Literal["component"] = "component"
component: RenderableComponent
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""
Markdown组件数据模型。
支持链式调用构建内容,例如:
ui.markdown("").text("hello").code("print(1)")
"""
style_name: str | None = None
elements: list[MarkdownElement] = Field(default_factory=list)
"""Markdown元素列表"""
width: int = 800
"""渲染区域宽度"""
css_path: str | None = None
"""自定义CSS文件路径"""
_context_stack: list[Any] = PrivateAttr(default_factory=list)
@property
def template_name(self) -> str:
return "components/core/markdown"
async def get_extra_css(self, context: Any) -> str:
css_parts = []
if self.component_css:
css_parts.append(self.component_css)
if self.css_path:
css_file = Path(self.css_path)
if await AsyncPath(css_file).is_file():
async with aiofiles.open(css_file, encoding="utf-8") as f:
css_parts.append(await f.read())
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "light"
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
css_parts.append(await f.read())
return "\n".join(css_parts)
def set_width(self, width: int) -> Self:
self.width = width
return self
def set_css_path(self, css_path: str) -> Self:
self.css_path = css_path
return self
def _append_element(self, element: MarkdownElement) -> Self:
if self._context_stack:
self._context_stack[-1].content.append(element)
else:
self.elements.append(element)
return self
def text(self, text: str) -> Self:
return self._append_element(TextElement(text=text))
def head(self, text: str, level: int = 1) -> Self:
return self._append_element(HeadingElement(text=text, level=level))
def image(self, content: str | Path, alt: str = "image") -> Self:
src = ""
if isinstance(content, Path):
src = content.absolute().as_uri()
elif content.startswith("base64://"):
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
else:
src = content
return self._append_element(ImageElement(src=src, alt=alt))
def code(self, code: str, language: str = "") -> Self:
return self._append_element(CodeElement(code=code, language=language))
def table(
self,
headers: list[str],
rows: list[list[str]],
alignments: list[Any] | None = None,
) -> Self:
return self._append_element(
TableElement(headers=headers, rows=rows, alignments=alignments)
)
def add_divider(self) -> Self:
return self._append_element(RawHtmlElement(html="---"))
def add_component(self, component: "RenderableComponent") -> Self:
return self._append_element(ComponentElement(component=component))
class _ContextManager:
def __init__(self, model: "MarkdownData", element: Any):
self.model = model
self.element = element
def __enter__(self):
self.model._context_stack.append(self.element)
return self.model
def __exit__(self, exc_type, exc_val, exc_tb):
self.model._context_stack.pop()
def quote(self) -> AbstractContextManager["MarkdownData"]:
element = QuoteElement()
self._append_element(element)
return self._ContextManager(self, element)
def list(self, ordered: bool = False) -> AbstractContextManager["MarkdownData"]:
element = ListElement(ordered=ordered)
self._append_element(element)
return self._ContextManager(self, element)
def list_item(self) -> AbstractContextManager["MarkdownData"]:
if not self._context_stack or not isinstance(
self._context_stack[-1], ListElement
):
raise TypeError("list_item() 只能在 list() 上下文中使用。")
element = ListItemElement()
self._context_stack[-1].content.append(element)
return self._ContextManager(self, element)
class BaseCell(BaseModel):
type: str
class TextCell(BaseCell):
type: Literal["text"] = "text" # type: ignore
"""单元格类型"""
content: str
"""文本内容"""
bold: bool = False
"""是否加粗"""
color: str | None = None
"""文本颜色"""
class ImageCell(BaseCell):
type: Literal["image"] = "image" # type: ignore
"""单元格类型"""
src: str
"""图片链接"""
width: int = 40
"""显示宽度"""
height: int = 40
"""显示高度"""
shape: Literal["square", "circle"] = "square"
"""图片形状"""
alt: str = "image"
"""替换文本"""
class StatusBadgeCell(BaseCell):
type: Literal["badge"] = "badge" # type: ignore
"""单元格类型"""
text: str
"""徽章文本"""
status_type: Literal["ok", "error", "warning", "info", "success"] = "info"
"""状态类型,决定颜色"""
class ProgressBarCell(BaseCell, ProgressBar):
type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
type: Literal["rich_text"] = "rich_text" # type: ignore
"""单元格类型"""
spans: list[TextSpan] = Field(default_factory=list)
"""富文本片段列表"""
direction: Literal["column", "row"] = Field("column")
"""排列方向"""
gap: str = "4px"
"""项目间距"""
class ComponentCell(BaseCell):
type: str = "component"
component: RenderableComponent
TableCell = (
TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
)
class TableData(RenderableComponent):
style_name: str | None = None
title: str
"""表格标题"""
tip: str | None = None
"""标题旁的提示文本"""
headers: list[str] = Field(default_factory=list)
"""表格头字段列表"""
rows: list[list[TableCell]] = Field(default_factory=list)
"""数据行列表"""
column_alignments: list[Literal["left", "center", "right"]] | None = None
"""各列的对齐方式"""
column_widths: list[str | int] | None = None
"""各列的宽度限制"""
@property
def template_name(self) -> str:
return "components/core/table"
def set_headers(self, headers: list[str]) -> Self:
"""设置表格标题行"""
self.headers = headers
return self
def set_column_alignments(
self, alignments: list[Literal["left", "center", "right"]]
) -> Self:
"""设置列对齐方式"""
self.column_alignments = alignments
return self
def set_column_widths(self, widths: list[str | int]) -> Self:
"""设置列宽度"""
self.column_widths = widths
return self
def _normalize_cell(self, cell_data: Any) -> BaseCell:
"""将任意数据标准化为 TableCell 类型"""
if isinstance(cell_data, BaseCell):
return cell_data
if isinstance(cell_data, str | int | float):
return TextCell(content=str(cell_data))
if cell_data is None:
return TextCell(content="")
return TextCell(content=str(cell_data))
def add_row(self, row: list[Any]) -> Self:
"""添加单行数据"""
normalized_row = [self._normalize_cell(cell) for cell in row]
self.rows.append(normalized_row) # type: ignore
return self
def add_rows(self, rows: list[list[Any]]) -> Self:
"""批量添加多行数据"""
for row in rows:
self.add_row(row)
return self
-27
View File
@@ -1,27 +0,0 @@
from typing import Any
from pydantic import BaseModel, Field
from .base import RenderableComponent
class DetailsItem(BaseModel):
"""描述列表中的单个项目"""
label: str = Field(..., description="项目的标签/键")
"""项目的标签/键"""
value: Any = Field(..., description="项目的值")
"""项目的值"""
class DetailsData(RenderableComponent):
"""描述列表(键值对)的数据模型"""
title: str | None = Field(None, description="列表的可选标题")
"""列表的可选标题"""
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
"""键值对项目列表"""
@property
def template_name(self) -> str:
return "components/core/details"
-58
View File
@@ -1,58 +0,0 @@
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field
from .base import ContainerComponent, RenderableComponent
__all__ = ["LayoutData", "LayoutItem"]
class LayoutItem(BaseModel):
"""布局中的单个项目,现在持有可渲染组件的数据模型"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent):
"""布局构建器的数据模型"""
style_name: str | None = None
"""应用于布局容器的样式名称"""
layout_type: str = "column"
"""布局类型 (如 'column', 'row', 'grid')"""
children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表"
)
"""要布局的项目列表"""
options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项"
)
"""传递给模板的选项"""
@property
def template_name(self) -> str:
return f"components/core/layouts/{self.layout_type}"
def get_extra_css(self, context: Any) -> str:
"""聚合所有子组件的 extra_css。"""
all_css = []
if self.component_css:
all_css.append(self.component_css)
for item in self.children:
if (
item.component
and hasattr(item.component, "component_css")
and item.component.component_css
):
all_css.append(item.component.component_css)
return "\n".join(all_css)
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.children:
yield item.component
-33
View File
@@ -1,33 +0,0 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel, Field
from .base import ContainerComponent, RenderableComponent
__all__ = ["ListData", "ListItem"]
class ListItem(BaseModel):
"""列表中的单个项目,其内容可以是任何可渲染组件。"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
class ListData(ContainerComponent):
"""通用列表的数据模型,支持有序和无序列表。"""
component_type: Literal["list"] = "list"
items: list[ListItem] = Field(default_factory=list, description="列表项目")
"""列表项目"""
ordered: bool = Field(default=False, description="是否为有序列表")
"""是否为有序列表"""
@property
def template_name(self) -> str:
return "components/core/list"
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.items:
yield item.component
-215
View File
@@ -1,215 +0,0 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Literal
import aiofiles
from pydantic import BaseModel, Field
from zhenxun.services.log import logger
from .base import ContainerComponent, RenderableComponent
__all__ = [
"CodeElement",
"ComponentElement",
"HeadingElement",
"ImageElement",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"QuoteElement",
"RawHtmlElement",
"TableElement",
"TextElement",
]
class MarkdownElement(BaseModel, ABC):
@abstractmethod
def to_markdown(self) -> str:
"""Serializes the element to its Markdown string representation."""
pass
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
text: str
def to_markdown(self) -> str:
return self.text
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
text: str
"""标题文本"""
level: int = Field(..., ge=1, le=6, description="标题级别 (1-6)")
"""标题级别 (1-6)"""
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
"""图片来源 (URL或data URI)"""
alt: str = "image"
"""图片的替代文本"""
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
"""代码字符串"""
language: str = ""
"""代码语言,用于语法高亮"""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
"""原始HTML字符串"""
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
"""表格的表头列表"""
rows: list[list[str]]
"""表格的数据行列表"""
alignments: list[Literal["left", "center", "right"]] | None = None
"""每列的对齐方式"""
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
if self.alignments:
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
separator_row = (
"| "
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
+ " |"
)
else:
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(
default_factory=list, description="容器内包含的Markdown元素列表"
)
"""容器内包含的Markdown元素列表"""
class QuoteElement(ContainerElement):
type: Literal["quote"] = "quote"
def to_markdown(self) -> str:
inner_md = "\n".join(part.to_markdown() for part in self.content)
return "\n".join([f"> {line}" for line in inner_md.split("\n")])
class ListItemElement(ContainerElement):
def to_markdown(self) -> str:
return "\n".join(part.to_markdown() for part in self.content)
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
"""是否为有序列表 (例如 1., 2.)"""
def to_markdown(self) -> str:
lines = []
for i, item in enumerate(self.content):
if isinstance(item, ListItemElement):
prefix = f"{i + 1}." if self.ordered else "*"
item_content = item.to_markdown()
lines.append(f"{prefix} {item_content}")
return "\n".join(lines)
class ComponentElement(MarkdownElement):
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
type: Literal["component"] = "component"
component: RenderableComponent
"""嵌入在Markdown中的可渲染组件"""
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型"""
style_name: str | None = None
"""Markdown内容的样式名称"""
elements: list[MarkdownElement] = Field(
default_factory=list, description="构成Markdown文档的元素列表"
)
"""构成Markdown文档的元素列表"""
width: int = 800
"""最终渲染图片的宽度"""
css_path: str | None = None
"""自定义CSS文件的绝对路径"""
@property
def template_name(self) -> str:
return "components/core/markdown"
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS/JS依赖收集器能够递归地找到所有嵌入的组件。"""
def find_components_recursive(
elements: list[MarkdownElement],
) -> Iterable[RenderableComponent]:
for element in elements:
if isinstance(element, ComponentElement):
yield element.component
if hasattr(element.component, "get_children"):
yield from element.component.get_children()
elif isinstance(element, ContainerElement):
yield from find_components_recursive(element.content)
yield from find_components_recursive(self.elements)
async def get_extra_css(self, context: Any) -> str:
css_parts = []
if self.component_css:
css_parts.append(self.component_css)
if self.css_path:
css_file = Path(self.css_path)
if css_file.is_file():
async with aiofiles.open(css_file, encoding="utf-8") as f:
css_parts.append(await f.read())
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "light"
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
css_parts.append(await f.read())
return "\n".join(css_parts)
-59
View File
@@ -1,59 +0,0 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel
from .base import ContainerComponent, RenderableComponent
__all__ = ["NotebookData", "NotebookElement"]
class NotebookElement(BaseModel):
"""一个 Notebook 页面中的单个元素"""
type: Literal[
"heading",
"paragraph",
"image",
"blockquote",
"code",
"list",
"divider",
"component",
]
text: str | None = None
"""元素的文本内容 (用于标题、段落、引用)"""
level: int | None = None
"""标题的级别 (1-4)"""
src: str | None = None
"""图片的来源 (URL或data URI)"""
caption: str | None = None
"""图片的说明文字"""
code: str | None = None
"""代码块的内容"""
language: str | None = None
"""代码块的语言"""
data: list[str] | None = None
"""列表项的内容列表"""
ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None
"""嵌入的自定义可渲染组件"""
class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型"""
style_name: str | None = None
"""Notebook的样式名称"""
elements: list[NotebookElement]
"""构成Notebook页面的元素列表"""
@property
def template_name(self) -> str:
return "components/core/notebook"
def get_children(self) -> Iterable[RenderableComponent]:
for element in self.elements:
if element.type == "component" and element.component:
yield element.component
-119
View File
@@ -1,119 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent
from .text import TextSpan
__all__ = [
"BaseCell",
"ComponentCell",
"ImageCell",
"ProgressBarCell",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TextCell",
]
class BaseCell(BaseModel):
"""单元格基础模型"""
type: str
class TextCell(BaseCell):
"""文本单元格"""
type: Literal["text"] = "text" # type: ignore
content: str
bold: bool = False
color: str | None = None
class ImageCell(BaseCell):
"""图片单元格"""
type: Literal["image"] = "image" # type: ignore
src: str
width: int = 40
height: int = 40
shape: Literal["square", "circle"] = "square"
alt: str = "image"
class StatusBadgeCell(BaseCell):
"""状态徽章单元格"""
type: Literal["badge"] = "badge" # type: ignore
text: str
status_type: Literal["ok", "error", "warning", "info"] = "info"
class ProgressBarCell(BaseCell, ProgressBar):
"""进度条单元格,继承ProgressBar模型以复用其字段"""
type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
"""富文本单元格,支持多个带样式的文本片段"""
type: Literal["rich_text"] = "rich_text" # type: ignore
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
direction: Literal["column", "row"] = Field("column", description="片段排列方向")
"""片段排列方向"""
gap: str = Field("4px", description="片段之间的间距")
"""片段之间的间距"""
class ComponentCell(BaseCell):
"""一个通用的单元格,可以容纳任何可渲染的组件。"""
type: str = "component"
component: RenderableComponent
TableCell = (
TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
)
class TableData(RenderableComponent):
"""通用表格的数据模型"""
style_name: str | None = None
"""应用于表格容器的样式名称"""
title: str = Field(..., description="表格主标题")
"""表格主标题"""
tip: str | None = Field(None, description="表格下方的提示信息")
"""表格下方的提示信息"""
headers: list[str] = Field(default_factory=list, description="表头列表")
"""表头列表"""
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
"""数据行列表"""
column_alignments: list[Literal["left", "center", "right"]] | None = Field(
default=None, description="每列的对齐方式"
)
"""每列的对齐方式"""
column_widths: list[str | int] | None = Field(
default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])"
)
"""每列的宽度 (e.g., ['50px', 'auto', 100])"""
@property
def template_name(self) -> str:
return "components/core/table"
-39
View File
@@ -1,39 +0,0 @@
from pathlib import Path
from typing import Any
from pydantic import Field
from .base import RenderableComponent
__all__ = ["TemplateComponent"]
class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件"""
_is_standalone_template: bool = True
"""标记此组件为独立模板"""
template_path: str | Path = Field(..., description="指向HTML模板文件的路径")
"""指向HTML模板文件的路径"""
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
"""传递给模板的上下文数据字典"""
@property
def template_name(self) -> str:
"""返回模板路径"""
if isinstance(self.template_path, Path):
return self.template_path.as_posix()
return str(self.template_path)
def get_render_data(self) -> dict[str, Any]:
"""返回传递给模板的数据"""
return self.data
def __getattr__(self, name: str) -> Any:
"""允许直接访问 `data` 字典中的属性。"""
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
-34
View File
@@ -1,34 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from .base import RenderableComponent
class TextSpan(BaseModel):
"""单个富文本片段的数据模型"""
text: str
bold: bool = False
italic: bool = False
underline: bool = False
strikethrough: bool = False
code: bool = False
color: str | None = None
font_size: str | None = None
font_family: str | None = None
class TextData(RenderableComponent):
"""轻量级富文本组件的数据模型"""
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
align: Literal["left", "right", "center"] = Field(
"left", description="整体文本对齐方式"
)
"""整体文本对齐方式"""
@property
def template_name(self) -> str:
return "components/core/text"
+120
View File
@@ -0,0 +1,120 @@
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, TypeVar
from zhenxun.services.log import logger
from zhenxun.services.renderer.types import Renderable
T = TypeVar("T", bound=Renderable)
@dataclass
class ComponentEntry:
"""组件注册条目,存储类与元数据的关联"""
component_class: type[Renderable]
default_template: str | None
class ComponentRegistry:
"""
UI 组件注册中心。
负责管理组件类的索引,并自动处理模板命名空间的注册。
"""
_instance: ClassVar = None
_registry: ClassVar[dict[str, ComponentEntry]] = {}
_class_template_map: ClassVar[dict[type[Renderable], str]] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@classmethod
def register(
cls,
name: str,
namespace: str = "core",
template: str | None = None,
template_root: Path | None = None,
) -> Callable[[type[T]], type[T]]:
"""
装饰器:注册一个 UI 组件。
参数:
name: 组件名称 (例如 'card')
namespace: 命名空间 (例如 'core' 或插件名)
template: (可选) 该组件默认绑定的模板路径 (例如 'components/card/main.html')
template_root: (可选) 该组件对应的模板根目录。
如果提供,会自动将其注册到 RendererService。
"""
def wrapper(component_cls: type[T]) -> type[T]:
full_key = f"{namespace}:{name}"
if full_key in cls._registry:
logger.warning(f"UI组件 '{full_key}' 已被注册,将被覆盖。")
cls._registry[full_key] = ComponentEntry(
component_class=component_cls, default_template=template
)
if template:
cls._class_template_map[component_cls] = template
if template_root:
if not template_root.exists():
logger.warning(
f"组件 '{full_key}' 提供的模板路径不存在: {template_root}"
)
else:
from zhenxun.services import renderer_service
renderer_service.register_template_namespace(
namespace, template_root
)
logger.debug(
f"已自动注册组件模板空间: @{namespace} -> {template_root}"
)
logger.trace(f"UI组件注册成功: {full_key} -> {component_cls.__name__}")
return component_cls
return wrapper
@classmethod
def get(cls, key: str) -> type[Renderable] | None:
"""根据 'namespace:name' 获取组件类。"""
entry = cls._registry.get(key)
return entry.component_class if entry else None
@classmethod
def get_template_for_class(cls, component_cls: type[Renderable]) -> str | None:
"""
根据组件类查找注册时绑定的默认模板。
支持子类继承查找 (可选,目前先做精确匹配)。
"""
return cls._class_template_map.get(component_cls)
@classmethod
def create(cls, key: str, **kwargs) -> Renderable:
"""
动态工厂方法。
参数:
key: 组件标识符 'namespace:name'
**kwargs: 传递给组件模型的初始化参数
"""
component_cls = cls.get(key)
if not component_cls:
if ":" not in key:
return cls.create(f"core:{key}", **kwargs)
raise ValueError(f"未找到 UI 组件: {key}")
return component_cls(**kwargs) # type: ignore
registry = ComponentRegistry()
component = registry.register
create = registry.create
+28
View File
@@ -1,3 +1,5 @@
from collections.abc import Callable
import random
import re
from typing import overload
@@ -5,6 +7,7 @@ from nonebot.adapters import Bot
from nonebot_plugin_uninfo import Session, SupportScope, Uninfo, get_interface
from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.cache.runtime_cache import (
BanMemoryCache,
@@ -95,6 +98,31 @@ class CommonUtils:
elif isinstance(data, list):
return "".join(cls.format(item) for item in data)
@staticmethod
def get_random_asset_factory(sub_path: str) -> Callable[[], str | None]:
"""
创建一个从指定 assets 子目录随机选取资源的工厂函数。
用于 Pydantic 模型的 default_factory。
参数:
sub_path: 相对于 themes/default/assets/ 的子路径,例如 "ui/zhenxun/down"
"""
def _factory() -> str | None:
target_dir = THEMES_PATH / "default" / "assets" / sub_path
if not target_dir.exists():
return None
images = [
f.name
for f in target_dir.iterdir()
if f.is_file()
and f.suffix.lower() in [".png", ".jpg", ".jpeg", ".webp"]
]
return f"{sub_path}/{random.choice(images)}" if images else None
return _factory
class SqlUtils:
@classmethod
+11 -8
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import random
from zhenxun import ui
from zhenxun.ui.builders import charts as chart_builders
from zhenxun.ui.models.charts import EChartsData
from .models import Barh
@@ -21,11 +21,14 @@ class ChartUtils:
if BACKGROUND_PATH.exists()
else None
)
items = list(zip(data.category_data, data.data))
builder = chart_builders.bar_chart(
title=data.title, items=items, direction="horizontal"
)
if background_image_name:
builder.set_background_image(background_image_name)
return await ui.render(builder.build())
items = list(zip(data.category_data, data.data))
chart_data = EChartsData.bar_chart(
title=data.title,
items=items, # type: ignore
direction="horizontal",
background_image=background_image_name,
)
return await ui.render(chart_data)
+25 -1
View File
@@ -46,6 +46,7 @@ def _sanitize_ui_html(html_string: str) -> str:
"""
专门用于净化UI渲染调试HTML的函数。
它会查找所有内联的base64数据(如字体、图片)并将其截断。
同时会折叠冗长的样式标签,避免主题 CSS 在日志中撑爆。
"""
if not isinstance(html_string, str):
return html_string
@@ -57,7 +58,30 @@ def _sanitize_ui_html(html_string: str) -> str:
original_len = len(match.group(0)) - len(prefix)
return f"{prefix}[...base64_omitted_len={original_len}...]"
return pattern.sub(replacer, html_string)
html_string = pattern.sub(replacer, html_string)
pattern_style = re.compile(
r"(<style[^>]*>)(.*?)(</style>)", re.DOTALL | re.IGNORECASE
)
def replacer_style(match):
start_tag, content, end_tag = match.group(1), match.group(2), match.group(3)
keywords = [
"Base Styles - Assembled by Jinja2",
"Utility Classes",
"@layer reset, base, components, utilities;",
]
if len(content) > 600 or any(keyword in content for keyword in keywords):
excerpt = (
f"\n /* [theme.css.jinja content hidden for brevity - "
f"{len(content)} chars] */\n"
)
return f"{start_tag}{excerpt}{end_tag}"
return match.group(0)
return pattern_style.sub(replacer_style, html_string)
def _sanitize_nonebot_message(message: Message) -> Message:
@@ -436,6 +436,14 @@ class ZhenxunRepoManagerClass:
branch: 分支名称
force: 是否强制更新
"""
critical_dir = self.config.RESOURCE_PATH / "themes" / "default"
if not critical_dir.exists() or not any(critical_dir.iterdir()):
logger.warning(
f"检测到关键资源目录 {critical_dir} 缺失或为空,将开启强制修复模式。",
LOG_COMMAND,
)
force = True
if await check_git():
await self.resources_git_update(source, branch, force)
logger.debug("使用git更新资源文件!", LOG_COMMAND)
+4 -3
View File
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
from pathlib import Path
import aiofiles
from anyio import Path as AsyncPath
from zhenxun.services.log import logger
@@ -258,10 +259,10 @@ class BaseRepoManager(ABC):
repo_url = prepare_repo_url(repo_url)
# 检查本地目录是否存在
if not local_path.exists():
if not await AsyncPath(local_path).exists():
# 如果不存在,则克隆仓库
logger.info(f"克隆仓库 {repo_url} 到 {local_path}", LOG_COMMAND)
success, stdout, stderr = await run_git_command(
success, _stdout, stderr = await run_git_command(
f"clone -b {branch} {repo_url} {local_path}"
)
if not success:
@@ -396,7 +397,7 @@ class BaseRepoManager(ABC):
result.new_version = new_version.strip()
# 如果版本相同,则无需更新
if old_version.strip() == new_version.strip():
if old_version.strip() == new_version.strip() and not force:
logger.info(
f"仓库 {repo_url} 已是最新版本: {new_version.strip()}", LOG_COMMAND
)