feat!(ui): 重构图表组件架构,实现数据与样式分离 (#2035)

*  feat!(ui): 重构图表组件架构,实现数据与样式分离

🏗️ **架构重构**
- 移除charts.py中所有硬编码样式参数(grid、tooltip、legend等)
- 将样式配置迁移至主题层style.json文件
- 统一图表模板消费样式文件的能力

📊 **图表组件优化**
- bar_chart: 移除grid和坐标轴show参数
- pie_chart: 移除tooltip、legend样式和series视觉参数
- line_chart: 移除tooltip、grid和坐标轴配置
- radar_chart: 移除tooltip硬编码

🎨 **主题系统增强**
- 新增pie_chart、line_chart、radar_chart的style.json配置
- 更新bar_chart/style.json,添加grid、xAxis、yAxis样式
- 所有图表模板支持deepMerge样式合并逻辑

🔧 **Breaking Changes**
- 图表工厂函数不再接受样式参数
- 主题开发者现可通过style.json完全定制图表外观
- 提升组件可维护性和主题灵活性

* 📦️ build(pyinstaller): 引入 resources.spec 并更新 .gitignore 规则

* 🚨 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 2025-08-28 09:20:15 +08:00 committed by GitHub
parent d9e65057cf
commit 7472cabd48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 2272 additions and 648 deletions

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
!resources.spec
# Installer logs
pip-log.txt

1
resources.spec Normal file
View File

@ -0,0 +1 @@
require_resources_version: ">=1.0.0"

View File

@ -109,8 +109,11 @@ async def _(
)
if name.available:
help_style = Config.get_config("help", "HELP_STYLE")
variant = help_style if help_style != "default" else None
traditional_help_result = await get_plugin_help(
session.user.id, name.result, _is_superuser
session.user.id, name.result, _is_superuser, variant=variant
)
is_plugin_found = not (

View File

@ -17,7 +17,6 @@ from zhenxun.services import (
)
from zhenxun.services.log import logger
from zhenxun.ui.builders import (
InfoCardBuilder,
NotebookBuilder,
PluginMenuBuilder,
)
@ -25,7 +24,6 @@ from zhenxun.ui.models import PluginMenuCategory
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump
from ._utils import classify_plugin
@ -164,13 +162,16 @@ def split_text(text: str):
return [s.replace(" ", "&nbsp;") for s in split_text]
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
async def get_plugin_help(
user_id: str, name: str, is_superuser: bool, variant: str | None = None
) -> str | bytes:
"""获取功能的帮助信息
参数:
user_id: 用户id
name: 插件名称或id
is_superuser: 是否为超级用户
variant: 使用的皮肤/变体名称
"""
type_list = await get_user_allow_help(user_id)
if name.isdigit():
@ -192,29 +193,32 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
return "该功能没有超级用户帮助信息"
usage = extra_data.superuser_help
builder = InfoCardBuilder(title=_plugin.metadata.name)
builder.add_metadata_items(
[
("作者", extra_data.author or "未知"),
("版本", extra_data.version or "未知"),
("调用次数", call_count),
]
)
metadata_items = [
{"label": "作者", "value": extra_data.author or "未知"},
{"label": "版本", "value": extra_data.version or "未知"},
{"label": "调用次数", "value": call_count},
]
processed_description = format_usage_for_markdown(
_plugin.metadata.description.strip()
)
processed_usage = format_usage_for_markdown(usage.strip())
builder.add_section("简介", [processed_description])
builder.add_section("使用方法", [processed_usage])
sections = [
{"title": "简介", "content": [processed_description]},
{"title": "使用方法", "content": [processed_usage]},
]
style_name = Config.get_config("help", "HELP_STYLE", "default")
render_dict = model_dump(builder._data)
render_dict["style_name"] = style_name
page_data = {
"title": _plugin.metadata.name,
"metadata": metadata_items,
"sections": sections,
}
return await ui.render_template("pages/builtin/help", data=render_dict)
component = ui.template("pages/builtin/help", data=page_data)
if variant:
component.variant = variant
return await ui.render(component, use_cache=True, device_scale_factor=2)
return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..."

View File

@ -4,7 +4,7 @@ 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.core.table import StatusBadgeCell, TextCell
from zhenxun.ui.models import StatusBadgeCell, TextCell
def _format_seconds(seconds: int) -> str:
@ -39,20 +39,19 @@ class Presenters:
return await renderer_service.render(builder.build())
column_name = ["提供商", "模型名称", "API类型", "状态"]
data_list = []
rows_data = []
for model in models:
is_available = model.get("is_available", True)
status_cell = StatusBadgeCell(
text="可用" if is_available else "不可用",
status_type="ok" if is_available else "error",
)
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
data_list.append(
rows_data.append(
[
TextCell(content=model.get("provider_name", "N/A")),
TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"),
TextCell(content=model.get("api_type", "N/A")),
status_cell,
StatusBadgeCell(
text="可用" if is_available else "不可用",
status_type="ok" if is_available else "error",
),
]
)
@ -60,7 +59,8 @@ class Presenters:
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
builder.set_headers(column_name)
builder.add_rows(data_list)
builder.set_column_alignments(["left", "left", "left", "center"])
builder.add_rows(rows_data)
return await renderer_service.render(builder.build(), use_cache=True)
@staticmethod

View File

@ -7,11 +7,9 @@ import aiofiles
import nonebot
from nonebot.drivers import Driver
from nonebot_plugin_uninfo import Uninfo
import pytz
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils
@ -176,18 +174,17 @@ async def _generate_html_card(
impression = float(user.impression)
user_console = await user.user_console
uid_str = (
f"{user_console.uid:08}"
if user_console and user_console.uid is not None
else "XXXXXXXX"
)
uid_formatted = f"{uid_str[:4]} {uid_str[4:]}"
if user_console and user_console.uid is not None:
uid = f"{user_console.uid}".rjust(12, "0")
uid_formatted = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
else:
uid_formatted = "XXXX XXXX XXXX"
level, next_impression, previous_impression = get_level_and_next_impression(
impression
)
attitude = level2attitude.get(str(level), "未知")
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
interpolation_val = max(0, next_impression - impression)
interpolation = f"{interpolation_val:.2f}"
@ -200,16 +197,21 @@ async def _generate_html_card(
hour = now.hour
if 6 < hour < 10:
bot_message = random.choice(MORNING_MESSAGE)
message = random.choice(MORNING_MESSAGE)
elif 0 <= hour < 6:
bot_message = random.choice(LG_MESSAGE)
message = random.choice(LG_MESSAGE)
else:
bot_message = f"{BotConfig.self_nickname}希望你开心!"
message = f"{BotConfig.self_nickname}希望你开心!"
bot_message = f"{BotConfig.self_nickname}说: {message}"
temperature = random.randint(1, 40)
weather_icon_name = f"{random.randint(0, 11)}.png"
tag_icon_name = f"{random.randint(0, 5)}.png"
font_size = 45
if len(nickname) > 6:
font_size = 27
user_info = {
"nickname": nickname,
"uid_str": uid_formatted,
@ -218,11 +220,17 @@ async def _generate_html_card(
)
or "",
"sign_count": user.sign_count,
"font_size": font_size,
}
favorability_info = {
"current": impression,
"level": level,
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
"attitude": f"对你的态度: {level2attitude.get(str(level), '未知')}",
"relation": lik2relation.get(str(level), "未知"),
"heart2": [1 for _ in range(level)],
"heart1": [1 for _ in range(len(lik2level) - level - 1)],
"next_level_at": next_impression,
"previous_level_at": previous_impression,
}
@ -241,15 +249,14 @@ async def _generate_html_card(
rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0
total_gold = user_console.gold if user_console else 0
last_log = (
await SignLog.filter(user_id=user.user_id).order_by("-create_time").first()
)
last_date = "从未"
if last_log:
last_date = str(
last_log.create_time.astimezone(pytz.timezone("Asia/Shanghai")).date()
)
last_sign_date_str = f"上次签到:{last_date}"
last_sign_date_str = ""
reward_info = {
"impression": f"好感度排名第 {rank}",
"gold": f"总金币:{total_gold}",
"gift": "",
"is_double": False,
}
else:
reward_info = {

View File

@ -1,8 +1,17 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Arparma,
Match,
Subcommand,
on_alconna,
)
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import renderer_service
from zhenxun.services.log import logger
@ -14,7 +23,9 @@ __plugin_meta__ = PluginMetadata(
description="管理UI、主题和渲染服务的相关配置",
usage="""
指令
重载UI主题
ui reload / 重载主题: 重新加载当前主题的配置和资源
ui theme / 主题列表: 显示所有可用的主题并高亮显示当前主题
ui theme [主题名称] / 切换主题 [主题名称]: 将UI主题切换为指定主题
""".strip(),
extra=PluginExtraData(
author="HibiKier",
@ -37,22 +48,39 @@ __plugin_meta__ = PluginMetadata(
default_value=True,
type=bool,
),
RegisterConfig(
module="UI",
key="DEBUG_MODE",
value=False,
help="是否在日志中输出渲染组件的完整HTML源码用于调试",
default_value=False,
type=bool,
),
],
).to_dict(),
)
_matcher = on_alconna(
Alconna("重载主题"),
ui_matcher = on_alconna(
Alconna(
"ui",
Subcommand("reload", help_text="重载当前主题"),
Subcommand("theme", Args["theme_name?", str], help_text="查看或切换主题"),
),
aliases={"主题管理"},
rule=to_me(),
permission=SUPERUSER,
priority=1,
block=True,
)
ui_matcher.shortcut("重载主题", command="ui reload")
ui_matcher.shortcut("主题列表", command="ui theme")
ui_matcher.shortcut("切换主题", command="ui theme", arguments=["{%0}"])
@_matcher.handle()
async def _(arparma: Arparma):
@ui_matcher.assign("reload")
async def handle_reload(arparma: Arparma):
theme_name = await renderer_service.reload_theme()
logger.info(
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
@ -60,3 +88,55 @@ async def _(arparma: Arparma):
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'").send(
reply_to=True
)
@ui_matcher.assign("theme")
async def handle_theme(
arparma: Arparma, theme_name_match: Match[str] = AlconnaMatch("theme_name")
):
if theme_name_match.available:
new_theme_name = theme_name_match.result
try:
await renderer_service.switch_theme(new_theme_name)
logger.info(
f"UI主题已切换为: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(
f"🎨 主题已成功切换为 '{new_theme_name}'"
).send(reply_to=True)
except FileNotFoundError as e:
logger.warning(
f"尝试切换到不存在的主题: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(str(e)).send(reply_to=True)
except Exception as e:
logger.error(
f"切换主题时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message(f"切换主题失败: {e}").send(reply_to=True)
else:
try:
available_themes = renderer_service.list_available_themes()
current_theme = Config.get_config("UI", "THEME", "default")
theme_list_str = "\n".join(
f" - {theme}{' <- 当前' if theme == current_theme else ''}"
for theme in sorted(available_themes)
)
response = f"🎨 可用主题列表:\n{theme_list_str}"
await MessageUtils.build_message(response).send(reply_to=True)
except Exception as e:
logger.error(
f"获取主题列表时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message("获取主题列表失败。").send(reply_to=True)

View File

@ -0,0 +1,13 @@
"""
渲染器服务的共享配置和常量
"""
RESERVED_TEMPLATE_KEYS: set[str] = {
"data",
"theme",
"theme_css",
"extra_css",
"required_scripts",
"required_styles",
"frameless",
}

View File

@ -33,6 +33,10 @@ class TemplateManifest(BaseModel):
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)"
)

View File

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable
from collections.abc import Awaitable, Iterable
from pathlib import Path
from typing import Any, Protocol
@ -9,26 +9,50 @@ 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"]:
"""
[新增] 返回一个包含所有直接子组件的可迭代对象
这使得渲染服务能够递归地遍历整个组件树以执行依赖收集CSSJS等任务
非容器组件应返回一个空列表
"""
...
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
return []
@abstractmethod
@ -36,13 +60,22 @@ class Renderable(ABC):
"""
返回一个将传递给模板的数据字典
重要字典的值可以是协程(Awaitable)渲染服务会自动解析它们
返回:
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据
"""
...
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
"""
[可选] 一个生命周期钩子让组件可以提供额外的CSS
可以返回 str awaitable[str]
参数:
context: 当前的渲染上下文对象可用于访问主题管理器等
返回:
str | Awaitable[str]: 注入到页面的额外CSS字符串
"""
return ""
@ -50,6 +83,8 @@ class Renderable(ABC):
class ScreenshotEngine(Protocol):
"""
一个协议定义了截图引擎的核心能力
这允许系统在不同的截图后端如Playwright, Pyppeteer之间切换
而无需修改上层渲染服务的代码
"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
@ -60,6 +95,9 @@ class ScreenshotEngine(Protocol):
html: 要渲染的HTML内容
base_url_path: 用于解析相对路径如CSS, JS, 图片的基础URL路径
**render_options: 传递给底层截图库的额外选项 ( viewport)
返回:
bytes: 渲染后的图片字节数据
"""
...
@ -67,6 +105,7 @@ class ScreenshotEngine(Protocol):
class RenderResult(BaseModel):
"""
渲染服务的统一返回类型
封装了渲染过程可能产出的所有结果主要用于调试和内部传递
"""
image_bytes: bytes | None = None

View File

@ -0,0 +1,32 @@
# 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()

View File

@ -1,13 +1,18 @@
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import hashlib
import inspect
from pathlib import Path
from typing import ClassVar, Literal
from typing import Any, ClassVar
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
from nonebot.utils import is_coroutine_callable
@ -17,33 +22,101 @@ from zhenxun.configs.config import Config
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
from zhenxun.services.log import logger
from zhenxun.utils.exception import RenderingError
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 .theme import ThemeManager
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)
class RendererService:
"""
图片渲染服务的统一门面
负责编排和调用底层渲染服务提供统一的渲染接口
支持多种渲染方式组件渲染模板渲染等
作为UI渲染的中心枢纽负责编排和调用底层服务提供统一的渲染接口
主要职责包括
- 管理和加载UI主题 (通过 ThemeManager)
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片
- 处理插件注册的模板过滤器和全局函数
- (可选) 管理渲染结果的缓存
"""
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
def __init__(self):
self._jinja_env: Environment | None = None
self._theme_manager: ThemeManager | None = None
self._screenshot_engine: ScreenshotEngine | None = None
self._initialized = False
self._init_lock = asyncio.Lock()
self._custom_filters: dict[str, Callable] = {}
self._custom_globals: dict[str, Callable] = {}
self._markdown_styles: dict[str, Path] = {}
self.filter("dump_json")(self._pydantic_tojson_filter)
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模板命名空间
这允许插件在自己的目录中维护模板并通过
`{% include '@namespace/template.html' %}` 的方式引用它们
避免了与核心或其他插件的模板命名冲突
参数:
namespace: 插件的唯一命名空间例如插件名
path: 包含该插件模板的目录路径
"""
if namespace in self._plugin_template_paths:
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
if not path.is_dir():
@ -52,18 +125,25 @@ class RendererService:
def register_markdown_style(self, name: str, path: Path):
"""
Markdown 渲染器注册一个具名样式
Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)
参数:
name (str): 样式的唯一名称例如 'cyberpunk'
path (Path): 指向该样式的CSS文件路径
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
if not path.is_file():
raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
asset_registry.register_markdown_style(name, path)
def filter(self, name: str) -> Callable:
"""
装饰器注册一个自定义 Jinja2 过滤器
参数:
name: 过滤器在模板中的调用名称
返回:
Callable: 用于装饰过滤器函数的装饰器
"""
def decorator(func: Callable) -> Callable:
@ -78,6 +158,12 @@ class RendererService:
def global_function(self, name: str) -> Callable:
"""
装饰器注册一个自定义 Jinja2 全局函数
参数:
name: 函数在模板中的调用名称
返回:
Callable: 用于装饰全局函数的装饰器
"""
def decorator(func: Callable) -> Callable:
@ -90,46 +176,143 @@ class RendererService:
return decorator
async def initialize(self):
"""[新增] 延迟初始化方法,在 on_startup 钩子中调用"""
"""
[新增] 延迟初始化方法 on_startup 钩子中调用
负责初始化截图引擎和主题管理器确保在首次渲染前所有依赖都已准备就绪
使用锁来防止并发初始化
"""
if self._initialized:
return
async with self._init_lock:
if self._initialized:
return
self._jinja_env = self._create_jinja_env()
self._jinja_env.filters.update(self._custom_filters)
self._jinja_env.globals.update(self._custom_globals)
self._screenshot_engine = get_screenshot_engine()
self._theme_manager = ThemeManager(
self._plugin_template_paths,
self._custom_filters,
self._custom_globals,
self._markdown_styles,
)
self._theme_manager = ThemeManager(self._jinja_env)
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
self._initialized = True
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)
manifest = await context.theme_manager.get_template_manifest(
component_path_base
)
style_paths_to_load = []
if manifest and manifest.styles:
styles = (
[manifest.styles]
if isinstance(manifest.styles, str)
else manifest.styles
)
for style_path in styles:
full_style_path = str(Path(component_path_base) / style_path).replace(
"\\", "/"
)
style_paths_to_load.append(full_style_path)
else:
resolved_template_name = (
await context.theme_manager._resolve_component_template(
component, context
)
)
conventional_style_path = str(
Path(resolved_template_name).with_name("style.css")
).replace("\\", "/")
style_paths_to_load.append(conventional_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)
async def _render_component(
self, component: Renderable, use_cache: bool = False, **render_options
self,
context: "RenderContext",
) -> RenderResult:
"""
核心的私有渲染方法执行完整的渲染流程
执行步骤:
1. **缓存检查**: 如果启用缓存则根据组件模板名和渲染数据生成缓存键
并尝试从文件系统中读取缓存图片
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子允许组件执行
异步数据加载
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树
收集所有需要的CSS文件JS文件和内联CSS
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串
此步骤会处理独立模板和主题内模板两种情况
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节
6. **缓存写入**: 如果缓存未命中且启用了缓存将生成的图片写入文件系统
"""
return await self._apply_caching_layer(self._render_component_core, context)
async def _apply_caching_layer(
self,
core_render_func: Callable[..., Awaitable[RenderResult]],
context: "RenderContext",
) -> RenderResult:
"""
一个高阶函数为核心渲染逻辑提供缓存层
它负责处理缓存的读取和写入而将实际的渲染工作委托给传入的函数
"""
cache_path = None
if Config.get_config("UI", "CACHE") and use_cache:
component = context.component
if Config.get_config("UI", "CACHE") and context.use_cache:
try:
template_name = component.template_name
data_dict = component.get_render_data()
resolved_data_dict = {}
for key, value in data_dict.items():
if is_coroutine_callable(value): # type: ignore
resolved_data_dict[key] = await value
else:
resolved_data_dict[key] = value
data_str = json.dumps(resolved_data_dict, sort_keys=True)
cache_key_str = f"{template_name}:{data_str}"
cache_filename = (
f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png"
@ -148,43 +331,46 @@ class RendererService:
logger.warning(f"UI缓存读取失败: {e}", e=e)
cache_path = None
result = await core_render_func(context)
if (
Config.get_config("UI", "CACHE")
and context.use_cache
and cache_path
and result.image_bytes
):
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(result.image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return result
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
"""
纯粹的核心渲染逻辑不包含任何缓存处理
此方法负责从组件数据模型生成最终的图片字节和HTML
"""
component = context.component
try:
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if hasattr(component, "prepare"):
await component.prepare()
required_scripts = set(component.get_required_scripts())
required_styles = set(component.get_required_styles())
if hasattr(component, "required_scripts"):
required_scripts.update(getattr(component, "required_scripts"))
if hasattr(component, "required_styles"):
required_styles.update(getattr(component, "required_styles"))
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 = {}
if manifest := await self._theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
assert context.theme_manager is not None, "ThemeManager 未初始化"
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
getattr(component, "_is_standalone_template", False)
and hasattr(component, "template_path")
hasattr(component, "template_path")
and isinstance(
template_path := getattr(component, "template_path"), Path
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
@ -195,45 +381,69 @@ class RendererService:
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals["theme"] = self._theme_manager.jinja_env.globals.get(
"theme", {}
temp_env.globals.update(context.theme_manager.jinja_env.globals)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.filters["md"] = self._theme_manager._markdown_filter
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
html_content = await template.render_async(data=data_dict)
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(render_options)
final_render_options.update(context.render_options)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=template_dir,
**final_render_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
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 = {}
if manifest := await context.theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(render_options)
final_render_options.update(context.render_options)
if not self._theme_manager.current_theme:
if not context.theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
html_content = await self._theme_manager._render_component_to_html(
component,
required_scripts=list(required_scripts),
required_styles=list(required_styles),
html_content = await context.theme_manager._render_component_to_html(
context,
**final_render_options,
)
@ -241,20 +451,12 @@ class RendererService:
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
except Exception as e:
@ -271,27 +473,37 @@ class RendererService:
self,
component: Renderable,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**render_options,
) -> bytes:
"""
统一的多态的渲染入口直接返回图片字节
参数:
component: 一个 Renderable 实例 ( RenderableComponent) 或一个
模板路径字符串
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)
use_cache: (可选) 是否启用渲染缓存默认为 False
**render_options: 传递给底层渲染引擎的额外参数
**render_options: 传递给底层截图引擎的额外参数例如 `viewport`
返回:
bytes: 渲染后的图片数据
bytes: 渲染后的PNG图片字节数据
异常:
RenderingError: 当渲染流程中任何步骤失败时抛出
"""
result = await self._render_component(
component,
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=use_cache,
**render_options,
render_options=render_options,
)
if debug_mode == "log" and result.html_content:
result = await self._render_component(context)
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
logger.info(
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
f"{result.html_content}\n"
@ -301,17 +513,44 @@ class RendererService:
raise RenderingError("渲染成功但未能生成图片字节数据。")
return result.image_bytes
async def render_to_html(self, component: Renderable) -> str:
"""调试方法只执行到HTML生成步骤。"""
async def render_to_html(
self, component: Renderable, frameless: bool = False
) -> str:
"""
调试方法只执行到HTML生成步骤不进行截图
参数:
component: 一个 `Renderable` 实例
frameless: 是否以无边框模式渲染只渲染HTML片段
返回:
str: 最终渲染出的完整HTML字符串
"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
return await self._theme_manager._render_component_to_html(component)
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
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
)
async def reload_theme(self) -> str:
"""
重新加载当前主题的配置和样式并清除缓存的Jinja环境
这在开发主题时非常有用可以热重载主题更改
返回:
str: 已成功加载的主题名称
"""
if not self._initialized:
await self.initialize()
@ -321,3 +560,37 @@ class RendererService:
await self._theme_manager.load_theme(current_theme_name)
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
return current_theme_name
def list_available_themes(self) -> list[str]:
"""获取所有可用主题的列表。"""
if not self._initialized or not self._theme_manager:
raise RuntimeError("ThemeManager尚未初始化。")
return self._theme_manager.list_available_themes()
async def switch_theme(self, theme_name: str) -> str:
"""
切换UI主题加载新主题并持久化配置
返回:
str: 已成功切换到的主题名称
"""
if not self._initialized or not self._theme_manager:
await self.initialize()
assert self._theme_manager is not None
available_themes = self._theme_manager.list_available_themes()
if theme_name not in available_themes:
raise FileNotFoundError(
f"主题 '{theme_name}' 不存在。可用主题: {', '.join(available_themes)}"
)
await self._theme_manager.load_theme(theme_name)
Config.set_config("UI", "THEME", theme_name, auto_save=True)
logger.info(f"UI主题已切换为: {theme_name}")
return theme_name
@staticmethod
def _pydantic_tojson_filter(obj: Any) -> str:
"""一个能够递归处理Pydantic模型及其集合的 tojson 过滤器"""
dumped_obj = _dump_pydantic_obj(obj)
return json.dumps(dumped_obj, ensure_ascii=False)

View File

@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import Callable
import inspect
import os
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import aiofiles
from jinja2 import (
@ -10,9 +12,10 @@ from jinja2 import (
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
pass_context,
)
import markdown
from markupsafe import Markup
from pydantic import BaseModel
import ujson as json
@ -20,9 +23,30 @@ from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.models import TemplateManifest
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.exception import RenderingError
from zhenxun.services.renderer.registry import asset_registry
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from .service import RenderContext
from .config import RESERVED_TEMPLATE_KEYS
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 Theme(BaseModel):
name: str
@ -33,41 +57,185 @@ class Theme(BaseModel):
class ThemeManager:
def __init__(
self,
plugin_template_paths: dict[str, Path],
custom_filters: dict[str, Callable],
custom_globals: dict[str, Callable],
markdown_styles: dict[str, Path],
):
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(
[
str(THEMES_PATH / "current_theme_placeholder" / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
final_loader = ChoiceLoader([prefix_loader, theme_loader])
def __init__(self, env: Environment):
"""
主题管理器负责UI主题的加载解析和模板渲染
self.jinja_env = Environment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
主要职责:
- 加载和管理UI主题包括 `palette.json` (调色板) `theme.css.jinja`(主题样式)
- 配置和持有核心的 Jinja2 环境实例
- Jinja2 环境注入全局函数 `asset()` `render()`供模板使用
- 实现`asset()`函数的资源解析逻辑支持皮肤组件主题和默认主题之间的资源回退
- 封装将 `Renderable` 组件渲染为最终HTML的复杂逻辑
"""
self.jinja_env = env
self.current_theme: Theme | None = None
self._custom_filters = custom_filters
self._custom_globals = custom_globals
self._markdown_styles = markdown_styles
self.jinja_env.globals["render"] = self._global_render_component
self.jinja_env.globals["asset"] = self._create_asset_loader()
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
self.jinja_env.filters["md"] = self._markdown_filter
def list_available_themes(self) -> list[str]:
"""扫描主题目录并返回所有可用的主题名称。"""
if not THEMES_PATH.is_dir():
return []
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
def _find_component_root(self, start_path: Path) -> Path:
"""
从给定的起始路径向上查找直到找到包含 manifest.json 的目录
这被认为是组件的根目录如果找不到则返回起始路径的目录
"""
current_path = start_path.parent
for _ in range(len(current_path.parts)):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _create_asset_loader(
self, local_base_path: Path | None = None
) -> Callable[..., str]:
"""
创建并返回一个用于解析静态资源的闭包函数 (Jinja2中的 `asset()` 函数)
该函数实现了强大的资源解析回退逻辑查找顺序如下:
1. **相对路径 (`./`)**: 优先查找相对于当前模板的 `assets` 目录
- 这支持组件皮肤 (`skins/`) 对其资源的覆盖
2. **当前主题**: 在当前激活主题的 `assets` 目录中查找
3. **默认主题**: 如果当前主题未找到则回退到 `default` 主题的 `assets` 目录
参数:
local_base_path: (可选) 当渲染独立模板时提供模板所在的目录
"""
@pass_context
def asset_loader(ctx, asset_path: str) -> str:
if asset_path.startswith("./"):
parent_template_name = ctx.environment.get_template(ctx.name).name
parent_template_abs_path = Path(
ctx.environment.loader.get_source(
ctx.environment, parent_template_name
)[1]
)
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
skin_asset_path = skin_dir / "assets" / asset_path[2:]
if skin_asset_path.exists():
logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'")
return skin_asset_path.absolute().as_uri()
logger.debug(
f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源"
)
component_root = self._find_component_root(parent_template_abs_path)
local_asset = component_root / "assets" / asset_path[2:]
if local_asset.exists():
logger.debug(f"找到组件公共资源: '{local_asset}'")
return local_asset.absolute().as_uri()
logger.warning(
f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader
def _create_standalone_asset_loader(
self, local_base_path: Path
) -> Callable[[str], str]:
"""
[新增] 为独立模板创建一个专用的更简单的 asset loader
"""
def asset_loader(asset_path: str) -> str:
if asset_path.startswith("./"):
local_file = local_base_path / "assets" / asset_path[2:]
if local_file.exists():
return local_file.absolute().as_uri()
logger.warning(
f"独立模板本地资源 '{asset_path}'"
f"'{local_base_path / 'assets'}' 中未找到。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader
async def _global_render_component(self, component: Renderable | None) -> str:
"""
一个全局的Jinja2函数用于在模板内部渲染子组件
它封装了查找模板设置上下文和渲染的逻辑
"""
if not component:
return ""
try:
class MockContext:
def __init__(self):
self.resolved_template_paths = {}
self.theme_manager = self
mock_context = MockContext()
template_path = await self._resolve_component_template(
component,
mock_context, # type: ignore
)
template = self.jinja_env.get_template(template_path)
template_context = {
"data": component,
"frameless": True,
}
render_data = component.get_render_data()
template_context.update(render_data)
return Markup(await template.render_async(**template_context))
except Exception as e:
logger.error(
f"在全局 render 函数中渲染组件 '{component.__class__.__name__}' 失败",
e=e,
)
return f"<!-- 组件渲染失败{component.__class__.__name__}: {e} -->"
@staticmethod
def _markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
@ -95,18 +263,30 @@ class ThemeManager:
theme_name = "default"
theme_dir = THEMES_PATH / "default"
default_palette_path = THEMES_PATH / "default" / "palette.json"
default_palette = (
json.loads(default_palette_path.read_text("utf-8"))
if default_palette_path.exists()
else {}
)
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
current_loaders = list(self.jinja_env.loader.loaders)
if len(current_loaders) > 1:
current_loaders[1] = FileSystemLoader(
[
str(theme_dir / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
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.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader])
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.jinja_env.loader = ChoiceLoader(current_loaders)
else:
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
palette_path = theme_dir / "palette.json"
palette = (
@ -126,42 +306,74 @@ class ThemeManager:
"default_assets_dir": THEMES_PATH / "default" / "assets",
}
self.jinja_env.globals["theme"] = theme_context_dict
self.jinja_env.globals["default_theme_palette"] = default_palette
logger.info(f"主题管理器已加载主题: {theme_name}")
async def _resolve_component_template(self, component_path: str) -> str:
async def _resolve_component_template(
self, component: Renderable, context: "RenderContext"
) -> str:
"""
智能解析组件路径
如果路径是目录则查找 manifest.json 以获取入口点
智能解析组件模板的路径支持简单组件和带皮肤(variant)的复杂组件
查找顺序如下:
1. **带皮肤的组件**: 如果组件定义了 `variant`则在
`components/{component_name}/skins/{variant_name}/` 目录下查找入口文件
2. **标准组件**: 在组件的根目录 `components/{component_name}/` 下查找入口文件
3. **兼容模式**: (作为最终回退)直接查找名为`components/{component_name}.html`
的文件
入口文件名默认为 `main.html`但可以被组件目录下的 `manifest.json` 文件中的
`entrypoint` 字段覆盖
"""
if Path(component_path).suffix:
return component_path
component_path_base = str(component.template_name)
manifest_path_str = f"{component_path}/manifest.json"
variant = getattr(component, "variant", None)
cache_key = f"{component_path_base}::{variant or 'default'}"
if cached_path := context.resolved_template_paths.get(cache_key):
logger.trace(f"模板路径缓存命中: '{cache_key}' -> '{cached_path}'")
return cached_path
if not self.jinja_env.loader:
raise TemplateNotFound(
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
if Path(component_path_base).suffix:
try:
self.jinja_env.get_template(component_path_base)
logger.debug(f"解析到直接模板路径: '{component_path_base}'")
return component_path_base
except TemplateNotFound as e:
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
raise e
entrypoint_filename = "main.html"
manifest = await self.get_template_manifest(component_path_base)
if manifest and manifest.entrypoint:
entrypoint_filename = manifest.entrypoint
potential_paths = []
if variant:
potential_paths.append(
f"{component_path_base}/skins/{variant}/{entrypoint_filename}"
)
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
entrypoint = manifest_data.get("entrypoint")
if not entrypoint:
raise RenderingError(
f"组件 '{component_path}' 的 manifest.json 中缺少 "
f"'entrypoint' 键。"
)
return f"{component_path}/{entrypoint}"
except TemplateNotFound:
logger.debug(
f"未找到 '{manifest_path_str}',将回退到默认的 'main.html' 入口点。"
)
return f"{component_path}/main.html"
raise TemplateNotFound(f"无法为组件 '{component_path}' 找到模板入口点。")
potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
if entrypoint_filename == "main.html":
potential_paths.append(f"{component_path_base}.html")
for path in potential_paths:
try:
self.jinja_env.get_template(path)
logger.debug(f"解析到模板路径: '{path}'")
context.resolved_template_paths[cache_key] = path
return path
except TemplateNotFound:
continue
err_msg = (
f"无法为组件 '{component_path_base}' 找到任何可用的模板。"
f"检查路径: {potential_paths}"
)
logger.error(err_msg)
raise TemplateNotFound(err_msg)
async def get_template_manifest(
self, component_path: str
@ -186,82 +398,115 @@ class ThemeManager:
return None
return None
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
async def resolve_markdown_style_path(
self, style_name: str, context: "RenderContext"
) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径
[新逻辑] 使用传入的上下文进行缓存
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
if cached_path := context.resolved_style_paths.get(style_name):
logger.trace(f"Markdown样式路径缓存命中: '{style_name}'")
return cached_path
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
return None
resolved_path: Path | None = None
if registered_path := asset_registry.resolve_markdown_style(style_name):
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
resolved_path = registered_path
elif self.current_theme:
theme_style_path = (
self.current_theme.assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if theme_style_path.exists():
logger.debug(
f"在主题 '{self.current_theme.name}' 中找到"
f"Markdown 样式: '{style_name}'"
)
resolved_path = theme_style_path
default_style_path = (
self.current_theme.default_assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if not resolved_path and default_style_path.exists():
logger.debug(f"'default' 主题中找到 Markdown 样式: '{style_name}'")
resolved_path = default_style_path
if resolved_path:
context.resolved_style_paths[style_name] = resolved_path
else:
logger.warning(
f"Markdown 样式 '{style_name}' 在注册表和主题目录中均未找到。"
)
return resolved_path
async def _render_component_to_html(
self,
component: Renderable,
required_scripts: list[str] | None = None,
required_styles: list[str] | None = None,
context: "RenderContext",
**kwargs,
) -> str:
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
if not self.current_theme:
await self.load_theme()
component = context.component
assert self.current_theme is not None, "主题加载失败"
data_dict = component.get_render_data()
custom_style_css = ""
if hasattr(component, "get_extra_css"):
css_result = component.get_extra_css(self)
if inspect.isawaitable(css_result):
custom_style_css = await css_result
else:
custom_style_css = css_result
def asset_loader(asset_path: str) -> str:
"""[新增] 用于在Jinja2模板中解析静态资源的辅助函数。"""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
theme_context_dict = model_dump(self.current_theme)
theme_context_dict["asset"] = asset_loader
theme_css_template = self.jinja_env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=theme_context_dict
)
resolved_template_name = await self._resolve_component_template(
str(component.template_name)
component, context
)
logger.debug(
f"正在渲染组件 '{component.template_name}' "
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
"RendererService",
"渲染服务",
)
if self._custom_filters:
self.jinja_env.filters.update(self._custom_filters)
if self._custom_globals:
self.jinja_env.globals.update(self._custom_globals)
template = self.jinja_env.get_template(resolved_template_name)
unpacked_data = {}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 'data.{key}' 访问。"
)
else:
unpacked_data[key] = value
template_context = {
"data": data_dict,
"data": component,
"theme": theme_context_dict,
"theme_css": "",
"custom_style_css": custom_style_css,
"required_scripts": required_scripts or [],
"required_styles": required_styles or [],
"frameless": kwargs.get("frameless", False),
}
template_context.update(unpacked_data)
template_context.update(kwargs)
return await template.render_async(**template_context)
html_fragment = await template.render_async(**template_context)
if not kwargs.get("frameless", False):
base_template = self.jinja_env.get_template("partials/_base.html")
page_context = {
"data": component,
"theme_css": theme_css_content,
"collected_inline_css": context.collected_inline_css,
"required_scripts": list(context.collected_scripts),
"collected_asset_styles": list(context.collected_asset_styles),
"body_content": html_fragment,
}
return await base_template.render_async(**page_context)
else:
return html_fragment

View File

@ -1,8 +1,9 @@
from pathlib import Path
from typing import Any, Literal
from typing import Any
from zhenxun.services.renderer.protocols import Renderable
from . import builders
from .builders.core.layout import LayoutBuilder
from .models.core.base import RenderableComponent
from .models.core.markdown import MarkdownData
@ -12,6 +13,14 @@ from .models.core.template import TemplateComponent
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
"""
创建一个基于独立模板文件的UI组件
适用于不希望遵循标准主题结构而是直接渲染单个HTML文件的场景
参数:
path: 指向HTML模板文件的绝对或相对路径
data: 传递给模板的上下文数据字典
返回:
TemplateComponent: 一个可被 `render()` 函数处理的组件实例
"""
if isinstance(path, str):
path = Path(path)
@ -22,15 +31,35 @@ def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
"""
创建一个基于Markdown内容的UI组件
参数:
content: 要渲染的Markdown字符串
style: (可选) Markdown的样式名称 'github-light'或一个指向
自定义CSS文件的路径
返回:
MarkdownData: 一个可被 `render()` 函数处理的组件实例
"""
builder = builders.MarkdownBuilder().text(content)
component = builder.build()
if isinstance(style, Path):
return MarkdownData(markdown=content, css_path=str(style.absolute()))
return MarkdownData(markdown=content, style_name=style)
component.css_path = str(style.absolute())
else:
component.style_name = style
return component
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
"""
创建一个垂直布局组件
便捷函数用于将多个组件垂直堆叠
参数:
children: 一个包含 `RenderableComponent` 实例的列表
**layout_options: 传递给布局模板的额外选项 `padding`, `gap`
返回:
LayoutBuilder: 一个配置好的垂直布局构建器
"""
builder = LayoutBuilder.column(**layout_options)
for child in children:
@ -41,6 +70,14 @@ def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuil
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
"""
创建一个水平布局组件
便捷函数用于将多个组件水平排列
参数:
children: 一个包含 `RenderableComponent` 实例的列表
**layout_options: 传递给布局模板的额外选项 `padding`, `gap`
返回:
LayoutBuilder: 一个配置好的水平布局构建器
"""
builder = LayoutBuilder.row(**layout_options)
for child in children:
@ -53,15 +90,25 @@ async def render(
data: dict | None = None,
*,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**kwargs,
) -> bytes:
"""
统一的UI渲染入口
这是第三方开发者最常用的函数用于将任何可渲染对象转换为图片
用法:
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
参数:
component_or_path: 一个 `Renderable` 实例或一个指向模板文件的
`str` `Path` 对象
data: (可选) `component_or_path` 是路径时必须提供此数据字典
use_cache: (可选) 是否为此渲染启用文件缓存默认为 `False`
**kwargs: 传递给底层截图引擎的额外参数例如 `viewport`
返回:
bytes: 渲染后的PNG图片字节数据
"""
from zhenxun.services import renderer_service
@ -73,9 +120,7 @@ async def render(
else:
component = component_or_path
return await renderer_service.render(
component, use_cache=use_cache, debug_mode=debug_mode, **kwargs
)
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
async def render_template(
@ -94,6 +139,9 @@ async def render_template(
返回:
bytes: 渲染后的图片数据
异常:
RenderingError: 渲染失败时抛出
"""
return await render(path, data, use_cache=use_cache, **kwargs)
@ -114,12 +162,16 @@ async def render_markdown(
返回:
bytes: 渲染后的图片数据
异常:
RenderingError: 渲染失败时抛出
"""
component: MarkdownData
builder = builders.MarkdownBuilder().text(md)
component = builder.build()
if isinstance(style, Path):
component = MarkdownData(markdown=md, css_path=str(style.absolute()))
component.css_path = str(style.absolute())
else:
component = MarkdownData(markdown=md, style_name=style)
component.style_name = style
return await render(component, use_cache=use_cache, **kwargs)
@ -131,10 +183,44 @@ async def render_full_result(
component: Renderable, use_cache: bool = False, **kwargs
) -> RenderResult:
"""
渲染组件并返回包含图片和HTML的完整结果对象用于调试和高级用途
渲染组件并返回包含图片和HTML的完整结果对象
主要用于调试或需要同时访问图片和其源HTML的场景
参数:
component: 一个 `Renderable` 实例
use_cache: (可选) 是否为此渲染启用文件缓存默认为 `False`
**kwargs: 传递给底层截图引擎的额外参数
返回:
RenderResult: 一个包含 `image_bytes` `html_content` 的Pydantic模型
"""
from zhenxun.services import renderer_service
from zhenxun.services.renderer.service import RenderContext
return await renderer_service._render_component(
component, use_cache=use_cache, **kwargs
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 未初始化"
context = RenderContext(
renderer=renderer_service,
theme_manager=renderer_service._theme_manager,
screenshot_engine=renderer_service._screenshot_engine,
component=component,
use_cache=use_cache,
render_options=kwargs,
)
return await renderer_service._render_component(context)
__all__ = [
"builders",
"hstack",
"markdown",
"render",
"render_full_result",
"render_markdown",
"render_template",
"template",
"vstack",
]

View File

@ -1,19 +1,49 @@
from . import widgets
from .core.layout import LayoutBuilder
from .core.markdown import MarkdownBuilder
from .core.notebook import NotebookBuilder
from .core.table import TableBuilder
from .presets.help_page import PluginHelpPageBuilder
from .presets.info_card import InfoCardBuilder
from .presets.plugin_menu import PluginMenuBuilder
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__ = [
"InfoCardBuilder",
"AlertBuilder",
"AvatarBuilder",
"AvatarGroupBuilder",
"BadgeBuilder",
"CardBuilder",
"DetailsBuilder",
"DividerBuilder",
"EChartsBuilder",
"KpiCardBuilder",
"LayoutBuilder",
"ListBuilder",
"MarkdownBuilder",
"NotebookBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
"ProgressBarBuilder",
"TableBuilder",
"widgets",
"TextBuilder",
"TimelineBuilder",
"UserInfoBlockBuilder",
]

View File

@ -7,14 +7,24 @@ T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
class BaseBuilder(Generic[T_DataModel]):
"""所有UI构建器的基类提供通用的样式化和构建逻辑。"""
"""
所有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._extra_css: str | None = None
self._component_css: str | None = None
self._variant: str | None = None
self._extra_classes: list[str] = []
@property
def data(self) -> T_DataModel:
@ -23,6 +33,12 @@ class BaseBuilder(Generic[T_DataModel]):
def with_style(self, style_name: str) -> Self:
"""
为组件应用一个特定的样式
参数:
style_name: 在主题的CSS中定义的样式类名
返回:
Self: 当前构建器实例以支持链式调用
"""
self._style_name = style_name
return self
@ -32,26 +48,70 @@ class BaseBuilder(Generic[T_DataModel]):
为组件的根元素应用动态的内联样式
参数:
style: 一个CSS样式字典例如 {"background-color":"#fff","font-size":"16px"}
style: 一个CSS样式字典例如
`{"background-color":"#fff","font-size":"16px"}`
返回:
Self: 当前构建器实例以支持链式调用
"""
self._inline_style = style
return self
def with_extra_css(self, css: str) -> 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._extra_css = css
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

View File

@ -2,87 +2,176 @@ from typing import Any, Generic, Literal, TypeVar
from typing_extensions import Self
from ..models.charts import (
BarChartData,
BaseChartData,
LineChartData,
LineChartSeries,
PieChartData,
PieChartDataItem,
EChartsAxis,
EChartsData,
EChartsGrid,
EChartsSeries,
EChartsTitle,
EChartsTooltip,
)
from .base import BaseBuilder
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
class BaseChartBuilder(BaseBuilder[T_ChartData], Generic[T_ChartData]):
"""所有图表构建器的基类"""
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
"""
一个统一的泛型的 ECharts 图表构建器
提供了设置 ECharts `option` 的核心方法以及一些常用图表的便利方法
"""
def set_title(self, title: str) -> Self:
self._data.title = title
return self
class BarChartBuilder(BaseChartBuilder[BarChartData]):
"""链式构建柱状图的辅助类 (支持横向和竖向)"""
def __init__(
self, title: str, direction: Literal["horizontal", "vertical"] = "horizontal"
):
data_model = BarChartData(
title=title, direction=direction, category_data=[], data=[]
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__(data_model, template_name="components/charts/bar_chart")
super().__init__(model, template_name=template_name)
def add_data(self, category: str, value: float) -> Self:
"""添加一个数据点"""
self._data.category_data.append(category)
self._data.data.append(value)
return self
def add_data_items(
self, items: list[tuple[str, int | float]] | list[dict[str, Any]]
def set_title(
self, text: str, left: Literal["left", "center", "right"] = "center"
) -> Self:
for item in items:
if isinstance(item, tuple):
self.add_data(item[0], item[1])
elif isinstance(item, dict):
self.add_data(item.get("category", ""), item.get("value", 0))
self._data.title_model = EChartsTitle(text=text, left=left)
return self
def set_background_image(self, background_image: str) -> Self:
"""设置背景图片 (仅横向柱状图模板支持)"""
self._data.background_image = background_image
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
class PieChartBuilder(BaseChartBuilder[PieChartData]):
"""链式构建饼图的辅助类"""
def __init__(self, title: str):
data_model = PieChartData(title=title, data=[])
super().__init__(data_model, template_name="components/charts/pie_chart")
def add_slice(self, name: str, value: float) -> Self:
"""添加一个饼图扇区"""
self._data.data.append(PieChartDataItem(name=name, value=value))
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
class LineChartBuilder(BaseChartBuilder[LineChartData]):
"""链式构建折线图的辅助类"""
def __init__(self, title: str):
data_model = LineChartData(title=title, category_data=[], series=[])
super().__init__(data_model, template_name="components/charts/line_chart")
def set_categories(self, categories: list[str]) -> Self:
"""设置X轴的分类标签"""
self._data.category_data = categories
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, name: str, data: list[int | float], smooth: bool = False
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
) -> Self:
"""添加一条折线"""
self._data.series.append(LineChartSeries(name=name, data=data, smooth=smooth))
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

View File

@ -0,0 +1,25 @@
"""
小组件构建器模块
包含各种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",
]

View File

@ -0,0 +1,23 @@
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

View File

@ -0,0 +1,38 @@
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

View File

@ -0,0 +1,20 @@
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")

View File

@ -0,0 +1,31 @@
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

View File

@ -0,0 +1,28 @@
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

View File

@ -3,14 +3,22 @@
包含基础的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",
]

View File

@ -0,0 +1,26 @@
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

View File

@ -0,0 +1,19 @@
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

View File

@ -19,16 +19,32 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
self._options: dict[str, Any] = {}
@classmethod
def column(cls, **options: Any) -> Self:
def column(
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
) -> Self:
builder = cls()
builder._template_name = "layouts/column"
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, **options: Any) -> Self:
def row(
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
) -> Self:
builder = cls()
builder._template_name = "layouts/row"
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
@ -56,15 +72,15 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
metadata: dict[str, Any] | None = None,
) -> Self:
"""
向布局中添加一个组件支持多种组件类型的添加
向布局中添加一个组件
参数:
component: 一个 Builder 实例 ( TableBuilder) 或一个 RenderableComponent
数据模型
metadata: (可选) 与此项目关联的元数据用于模板
component: 一个 `BaseBuilder` 实例 ( `TableBuilder()`) 或一个已构建的
`RenderableComponent` 数据模型
metadata: (可选) 与此项目关联的元数据在布局模板中访问
返回:
Self: 返回当前布局构建器实例支持链式调用
Self: 当前构建器实例支持链式调用
"""
component_data = (
component.data if isinstance(component, BaseBuilder) else component
@ -76,28 +92,24 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
def add_option(self, key: str, value: Any) -> Self:
"""
为布局添加一个自定义选项该选项会传递给模板
为布局模板添加一个自定义选项
例如`add_option("padding", "30px")` 会在模板的 `data.options`
字典中添加 `{"padding": "30px"}`
参数:
key: 选项的键名用于在模板中引用
value: 选项的值可以是任意类型的数据
key: 选项的键名
value: 选项的值
返回:
Self: 返回当前布局构建器实例支持链式调用
Self: 当前构建器实例支持链式调用
"""
self._options[key] = value
return self
def build(self) -> LayoutData:
"""
[修改] 构建并返回 LayoutData 模型实例
此方法现在是同步的并且不执行渲染
参数:
返回:
LayoutData: 配置好的布局数据模型
构建并返回 LayoutData 模型实例
"""
if not self._template_name:
raise ValueError(
@ -106,4 +118,4 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
self._data.options = self._options
self._data.layout_type = self._template_name.split("/")[-1]
return self._data
return super().build()

View File

@ -0,0 +1,31 @@
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

View File

@ -4,6 +4,7 @@ from typing import Any
from ...models.core.markdown import (
CodeElement,
ComponentElement,
HeadingElement,
ImageElement,
ListElement,
@ -12,6 +13,7 @@ from ...models.core.markdown import (
MarkdownElement,
QuoteElement,
RawHtmlElement,
RenderableComponent,
TableElement,
TextElement,
)
@ -24,7 +26,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
"""链式构建Markdown图片的辅助类支持上下文管理和组合。"""
def __init__(self):
data_model = MarkdownData(markdown="", width=800, css_path=None)
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
@ -78,6 +80,16 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
)
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:
@ -144,8 +156,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
"""
构建并返回 MarkdownData 模型实例
"""
final_markdown = "\n\n".join(part.to_markdown() for part in self._parts).strip()
self._data.markdown = final_markdown
self._data.elements = self._parts
self._data.width = self._width
self._data.css_path = self._css_path
return super().build()

View File

@ -1,3 +1,5 @@
from typing import Literal
from ...models.core.table import TableCell, TableData
from ..base import BaseBuilder
@ -12,16 +14,61 @@ class TableBuilder(BaseBuilder[TableData]):
super().__init__(data_model, template_name="components/core/table")
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: 当前构建器实例以支持链式调用
"""
self._data.rows.append(row)
return self
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
"""批量添加多行数据"""
"""
向表格中批量添加多行数据
参数:
rows: 一个包含多行数据的列表
返回:
TableBuilder: 当前构建器实例以支持链式调用
"""
self._data.rows.extend(rows)
return self

View File

@ -0,0 +1,62 @@
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

View File

@ -3,12 +3,10 @@
包含预定义的UI组件构建器
"""
from .help_page import PluginHelpPageBuilder
from .info_card import InfoCardBuilder
from .plugin_help_page import PluginHelpPageBuilder
from .plugin_menu import PluginMenuBuilder
__all__ = [
"InfoCardBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
]

View File

@ -1,46 +0,0 @@
from typing import Any
from ...models.presets.card import (
InfoCardData,
InfoCardMetadataItem,
InfoCardSection,
)
from ..base import BaseBuilder
__all__ = ["InfoCardBuilder"]
class InfoCardBuilder(BaseBuilder[InfoCardData]):
def __init__(self, title: str):
self._data = InfoCardData(title=title)
super().__init__(self._data, template_name="components/presets/info_card")
def add_metadata(self, label: str, value: str | int) -> "InfoCardBuilder":
self._data.metadata.append(InfoCardMetadataItem(label=label, value=value))
return self
def add_metadata_items(
self, items: list[tuple[str, Any]] | list[dict[str, Any]]
) -> "InfoCardBuilder":
for item in items:
if isinstance(item, tuple):
self.add_metadata(item[0], item[1])
elif isinstance(item, dict):
self.add_metadata(item.get("label", ""), item.get("value", ""))
return self
def add_section(self, title: str, content: str | list[str]) -> "InfoCardBuilder":
content_list = [content] if isinstance(content, str) else content
self._data.sections.append(InfoCardSection(title=title, content=content_list))
return self
def add_sections(
self, sections: list[tuple[str, str | list[str]]] | list[dict[str, Any]]
) -> "InfoCardBuilder":
for section in sections:
if isinstance(section, tuple):
self.add_section(section[0], section[1])
elif isinstance(section, dict):
self.add_section(section.get("title", ""), section.get("content", []))
return self

View File

@ -1,4 +1,4 @@
from ...models.presets.help_page import (
from ...models.presets.plugin_help_page import (
HelpCategory,
PluginHelpPageData,
)
@ -13,7 +13,7 @@ class PluginHelpPageBuilder(BaseBuilder[PluginHelpPageData]):
bot_nickname=bot_nickname, page_title=page_title, categories=[]
)
super().__init__(self._data, template_name="pages/core/help_page")
super().__init__(self._data, template_name="pages/core/plugin_help_page")
def add_category(self, category: HelpCategory) -> "PluginHelpPageBuilder":
"""添加一个帮助分类"""

View File

@ -1,14 +0,0 @@
"""
小组件构建器模块
包含各种UI小组件的构建器
"""
from .badge import BadgeBuilder
from .progress_bar import ProgressBarBuilder
from .user_info_block import UserInfoBlockBuilder
__all__ = [
"BadgeBuilder",
"ProgressBarBuilder",
"UserInfoBlockBuilder",
]

View File

@ -1,70 +1,67 @@
from .charts import (
BarChartData,
BaseChartData,
LineChartData,
LineChartSeries,
PieChartData,
PieChartDataItem,
EChartsData,
)
from .components.badge import Badge
from .components.divider import Divider, Rectangle
from .components.progress_bar import ProgressBar
from .components.user_info_block import UserInfoBlock
from .core.base import RenderableComponent
from .core.layout import LayoutData, LayoutItem
from .core.markdown import (
from .components import (
Badge,
Divider,
ProgressBar,
Rectangle,
UserInfoBlock,
)
from .core import (
BaseCell,
CodeElement,
HeadingElement,
ImageCell,
ImageElement,
LayoutData,
LayoutItem,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
NotebookData,
NotebookElement,
QuoteElement,
RawHtmlElement,
TableElement,
TextElement,
)
from .core.notebook import NotebookData, NotebookElement
from .core.table import (
BaseCell,
ImageCell,
RenderableComponent,
StatusBadgeCell,
TableCell,
TableData,
TableElement,
TextCell,
TextElement,
)
from .presets import (
HelpCategory,
HelpItem,
PluginHelpPageData,
PluginMenuCategory,
PluginMenuData,
PluginMenuItem,
)
from .presets.card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .presets.help_page import HelpCategory, HelpItem, PluginHelpPageData
from .presets.plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"Badge",
"BarChartData",
"BaseCell",
"BaseChartData",
"CodeElement",
"Divider",
"EChartsData",
"HeadingElement",
"HelpCategory",
"HelpItem",
"ImageCell",
"ImageElement",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"LayoutData",
"LayoutItem",
"LineChartData",
"LineChartSeries",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"NotebookData",
"NotebookElement",
"PieChartData",
"PieChartDataItem",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",

View File

@ -1,63 +1,122 @@
from typing import Literal
from abc import ABC, abstractmethod
from typing import Any, Literal
import uuid
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import model_dump
from .core.base import RenderableComponent
class BaseChartData(RenderableComponent):
class EChartsTitle(BaseModel):
text: str
left: Literal["left", "center", "right"] = "center"
class EChartsAxis(BaseModel):
type: Literal["category", "value", "time", "log"]
data: list[Any] | None = None
show: bool = True
class EChartsSeries(BaseModel):
type: str
data: list[Any]
name: str | None = None
label: dict[str, Any] | None = None
itemStyle: dict[str, Any] | None = None
barMaxWidth: int | None = None
smooth: bool | None = None
class EChartsTooltip(BaseModel):
trigger: Literal["item", "axis", "none"] = "item"
class EChartsGrid(BaseModel):
left: str | None = None
right: str | None = None
top: str | None = None
bottom: str | None = None
containLabel: bool = True
class BaseChartData(RenderableComponent, ABC):
"""所有图表数据模型的基类"""
style_name: str | None = None
title: str
chart_id: str = Field(default_factory=lambda: f"chart-{uuid.uuid4().hex}")
echarts_options: dict[str, Any] | None = None
@abstractmethod
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
raise NotImplementedError
def get_render_data(self) -> dict[str, Any]:
"""为图表组件定制渲染数据,动态构建最终的 option 对象。"""
dumped_data = model_dump(self, exclude={"template_path"})
if hasattr(self, "build_option"):
dumped_data["option"] = self.build_option()
return dumped_data
def get_required_scripts(self) -> list[str]:
"""声明此组件需要 ECharts 库。"""
return ["js/echarts.min.js"]
class BarChartData(BaseChartData):
"""柱状图(支持横向和竖向)的数据模型"""
class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型"""
category_data: list[str]
data: list[int | float]
direction: Literal["horizontal", "vertical"] = "horizontal"
background_image: str | None = None
template_path: str = Field(..., exclude=True)
title_model: EChartsTitle | None = Field(None, alias="title")
grid_model: EChartsGrid | None = Field(None, alias="grid")
tooltip_model: EChartsTooltip | None = Field(None, alias="tooltip")
x_axis_model: EChartsAxis | None = Field(None, alias="xAxis")
y_axis_model: EChartsAxis | None = Field(None, alias="yAxis")
series_models: list[EChartsSeries] = Field(default_factory=list, alias="series")
legend_model: dict[str, Any] | None = Field(default_factory=dict, alias="legend")
raw_options: dict[str, Any] = Field(
default_factory=dict, description="用于 set_option 的原始覆盖选项"
)
background_image: str | None = Field(
None, description="【兼容】用于横向柱状图的背景图片"
)
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
option: dict[str, Any] = {}
key_map = {
"title": "title_model",
"grid": "grid_model",
"tooltip": "tooltip_model",
"xAxis": "x_axis_model",
"yAxis": "y_axis_model",
"series": "series_models",
"legend": "legend_model",
}
for echarts_key, model_attr in key_map.items():
model_instance = getattr(self, model_attr, None)
if model_instance:
if isinstance(model_instance, list):
option[echarts_key] = [
model_dump(m, exclude_none=True) for m in model_instance
]
elif isinstance(model_instance, BaseModel):
option[echarts_key] = model_dump(model_instance, exclude_none=True)
else:
option[echarts_key] = model_instance
option.update(self.raw_options)
return option
@property
def title(self) -> str:
"""为模板提供一个简单的字符串标题,保持向后兼容性。"""
return self.title_model.text if self.title_model else ""
@property
def template_name(self) -> str:
return "components/charts/bar_chart"
class PieChartDataItem(BaseModel):
name: str
value: int | float
class PieChartData(BaseChartData):
"""饼图的数据模型"""
data: list[PieChartDataItem]
@property
def template_name(self) -> str:
return "components/charts/pie_chart"
class LineChartSeries(BaseModel):
name: str
data: list[int | float]
smooth: bool = False
class LineChartData(BaseChartData):
"""折线图的数据模型"""
category_data: list[str]
series: list[LineChartSeries]
@property
def template_name(self) -> str:
return "components/charts/line_chart"
return self.template_path

View File

@ -3,15 +3,22 @@
包含各种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
__all__ = [
"Alert",
"Badge",
"Divider",
"KpiCard",
"ProgressBar",
"Rectangle",
"Timeline",
"TimelineItem",
"UserInfoBlock",
]

View File

@ -0,0 +1,23 @@
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"

View File

@ -0,0 +1,35 @@
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")
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'"
)
@property
def template_name(self) -> str:
return "components/widgets/avatar"

View File

@ -0,0 +1,29 @@
from typing import Any, Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["KpiCard"]
class KpiCard(RenderableComponent):
"""一个用于展示关键性能指标KPI的统计卡片。"""
component_type: Literal["kpi_card"] = "kpi_card"
label: str = Field(..., description="指标的标签或名称")
value: Any = Field(..., description="指标的主要数值")
unit: str | None = Field(default=None, description="数值的单位,可选")
change: str | None = Field(
default=None, description="与上一周期的变化,例如 '+15%''-100'"
)
change_type: Literal["positive", "negative", "neutral"] = Field(
default="neutral", description="变化的类型,用于决定颜色"
)
icon_svg: str | None = Field(
default=None, description="卡片中显示的可选图标 (SVG path data)"
)
@property
def template_name(self) -> str:
return "components/widgets/kpi_card"

View File

@ -0,0 +1,30 @@
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路径")
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"

View File

@ -4,7 +4,10 @@
"""
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 (
CodeElement,
HeadingElement,
@ -21,16 +24,22 @@ from .markdown import (
from .notebook import NotebookData, NotebookElement
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell
from .template import TemplateComponent
from .text import TextData, TextSpan
__all__ = [
"BaseCell",
"CardData",
"CodeElement",
"DetailsData",
"DetailsItem",
"HeadingElement",
"ImageCell",
"ImageElement",
"LayoutData",
"LayoutItem",
"ListData",
"ListElement",
"ListItem",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
@ -45,5 +54,7 @@ __all__ = [
"TableElement",
"TemplateComponent",
"TextCell",
"TextData",
"TextElement",
"TextSpan",
]

View File

@ -1,20 +1,29 @@
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Iterator
from collections.abc import Awaitable, Iterable
from typing import Any
from nonebot.compat import model_dump
from pydantic import BaseModel
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
__all__ = ["ContainerComponent", "RenderableComponent"]
class RenderableComponent(BaseModel, Renderable):
"""所有可渲染UI组件的抽象基类。"""
"""
所有可渲染UI组件的数据模型基类
它继承自 Pydantic `BaseModel` 用于数据校验和结构化同时实现了 `Renderable`
协议确保其能够被 `RendererService` 正确处理
它还提供了一些所有组件通用的样式属性 `inline_style`, `variant`
"""
_is_standalone_template: bool = False
inline_style: dict[str, str] | None = None
component_css: str | None = None
extra_classes: list[str] | None = None
variant: str | None = None
@property
def template_name(self) -> str:
@ -30,6 +39,10 @@ class RenderableComponent(BaseModel, Renderable):
"""[可选] 生命周期钩子,默认无操作。"""
pass
def get_children(self) -> Iterable["RenderableComponent"]:
"""默认实现:非容器组件没有子组件。"""
return []
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
return []
@ -40,9 +53,18 @@ class RenderableComponent(BaseModel, Renderable):
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""默认实现,返回模型自身的数据字典。"""
return model_dump(self)
return model_dump(
self, exclude={"inline_style", "component_css", "inline_style_str"}
)
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
@compat_computed_field
def inline_style_str(self) -> str:
"""[新增] 一个辅助属性将内联样式字典转换为CSS字符串"""
if not self.inline_style:
return ""
return "; ".join(f"{k}: {v}" for k, v in self.inline_style.items())
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
return ""
@ -52,37 +74,24 @@ class ContainerComponent(RenderableComponent, ABC):
"""
@abstractmethod
def _get_renderable_child_items(self) -> Iterator[Any]:
def get_children(self) -> Iterable[RenderableComponent]:
"""
一个抽象方法子类必须实现它来返回一个可迭代的对象
迭代器中的每个项目都必须具有 'component' 'html_content' 属性
一个抽象方法子类必须实现它来返回一个可迭代的子组件
"""
raise NotImplementedError
async def prepare(self) -> None:
"""
通用的 prepare 方法负责预渲染所有子组件
"""
from zhenxun.services import renderer_service
def get_required_scripts(self) -> list[str]:
"""[新增] 聚合所有子组件的脚本依赖。"""
scripts = set(super().get_required_scripts())
for child in self.get_children():
if child:
scripts.update(child.get_required_scripts())
return list(scripts)
child_items = list(self._get_renderable_child_items())
if not child_items:
return
components_to_render = [
item.component for item in child_items if item.component
]
prepare_tasks = [
comp.prepare() for comp in components_to_render if hasattr(comp, "prepare")
]
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
render_tasks = [
renderer_service.render_to_html(comp) for comp in components_to_render
]
rendered_htmls = await asyncio.gather(*render_tasks)
for item, html in zip(child_items, rendered_htmls):
item.html_content = html
def get_required_styles(self) -> list[str]:
"""[新增] 聚合所有子组件的样式依赖。"""
styles = set(super().get_required_styles())
for child in self.get_children():
if child:
styles.update(child.get_required_styles())
return list(styles)

View File

@ -0,0 +1,24 @@
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

View File

@ -0,0 +1,23 @@
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"

View File

@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field
@ -12,7 +13,6 @@ class LayoutItem(BaseModel):
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
html_content: str | None = None
class LayoutData(ContainerComponent):
@ -27,23 +27,26 @@ class LayoutData(ContainerComponent):
default_factory=dict, description="传递给模板的选项"
)
def get_required_scripts(self) -> list[str]:
"""[新增] 聚合所有子组件的脚本依赖。"""
scripts = set()
for item in self.children:
scripts.update(item.component.get_required_scripts())
return list(scripts)
def get_required_styles(self) -> list[str]:
"""[新增] 聚合所有子组件的样式依赖。"""
styles = set()
for item in self.children:
styles.update(item.component.get_required_styles())
return list(styles)
@property
def template_name(self) -> str:
return f"layouts/{self.layout_type}"
return f"components/core/layouts/{self.layout_type}"
def _get_renderable_child_items(self):
yield from self.children
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

View File

@ -0,0 +1,30 @@
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

View File

@ -1,16 +1,18 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Literal
from typing import Any, Literal
import aiofiles
from pydantic import BaseModel, Field
from zhenxun.services.log import logger
from .base import RenderableComponent
from .base import ContainerComponent, RenderableComponent
__all__ = [
"CodeElement",
"ComponentElement",
"HeadingElement",
"ImageElement",
"ListElement",
@ -32,6 +34,7 @@ class MarkdownElement(BaseModel, ABC):
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
text: str
def to_markdown(self) -> str:
@ -39,6 +42,7 @@ class TextElement(MarkdownElement):
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
text: str
level: int = Field(..., ge=1, le=6)
@ -47,6 +51,7 @@ class HeadingElement(MarkdownElement):
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
alt: str = "image"
@ -55,6 +60,7 @@ class ImageElement(MarkdownElement):
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
language: str = ""
@ -63,6 +69,7 @@ class CodeElement(MarkdownElement):
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
def to_markdown(self) -> str:
@ -70,6 +77,7 @@ class RawHtmlElement(MarkdownElement):
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
rows: list[list[str]]
alignments: list[Literal["left", "center", "right"]] | None = None
@ -98,6 +106,8 @@ class ContainerElement(MarkdownElement):
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")])
@ -109,6 +119,7 @@ class ListItemElement(ContainerElement):
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
def to_markdown(self) -> str:
@ -121,11 +132,21 @@ class ListElement(ContainerElement):
return "\n".join(lines)
class MarkdownData(RenderableComponent):
class ComponentElement(MarkdownElement):
"""一个特殊的元素用于在Markdown流中持有另一个可渲染组件。"""
type: Literal["component"] = "component"
component: RenderableComponent
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型"""
style_name: str | None = None
markdown: str
elements: list[MarkdownElement] = Field(default_factory=list)
width: int = 800
css_path: str | None = None
@ -133,7 +154,23 @@ class MarkdownData(RenderableComponent):
def template_name(self) -> str:
return "components/core/markdown"
async def get_extra_css(self, theme_manager) -> str:
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:
if self.css_path:
css_file = Path(self.css_path)
if css_file.is_file():
@ -142,14 +179,12 @@ class MarkdownData(RenderableComponent):
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "github-light"
css_path = (
theme_manager.current_theme.default_assets_dir
/ "css"
/ "markdown"
/ f"{style_name}.css"
style_name = self.style_name or "light"
# 使用上下文对象来解析路径
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path.exists():
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
return await f.read()
return ""

View File

@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel
@ -29,7 +30,6 @@ class NotebookElement(BaseModel):
data: list[str] | None = None
ordered: bool | None = None
component: RenderableComponent | None = None
html_content: str | None = None
class NotebookData(ContainerComponent):
@ -42,7 +42,7 @@ class NotebookData(ContainerComponent):
def template_name(self) -> str:
return "components/core/notebook"
def _get_renderable_child_items(self):
def get_children(self) -> Iterable[RenderableComponent]:
for element in self.elements:
if element.type == "component" and element.component:
yield element
yield element.component

View File

@ -2,11 +2,13 @@ from typing import Literal
from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent
__all__ = [
"BaseCell",
"ImageCell",
"ProgressBarCell",
"StatusBadgeCell",
"TableCell",
"TableData",
@ -48,7 +50,15 @@ class StatusBadgeCell(BaseCell):
status_type: Literal["ok", "error", "warning", "info"] = "info"
TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None
class ProgressBarCell(BaseCell, ProgressBar):
"""进度条单元格继承ProgressBar模型以复用其字段"""
type: Literal["progress_bar"] = "progress_bar" # type: ignore
TableCell = (
TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None
)
class TableData(RenderableComponent):
@ -59,6 +69,12 @@ class TableData(RenderableComponent):
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])"
)
@property
def template_name(self) -> str:

View File

@ -23,3 +23,12 @@ class TemplateComponent(RenderableComponent):
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

View File

@ -0,0 +1,32 @@
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"

View File

@ -3,16 +3,12 @@
包含预定义的复合组件数据模型
"""
from .card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"HelpCategory",
"HelpItem",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",

View File

@ -1,36 +0,0 @@
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = [
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
]
class InfoCardMetadataItem(BaseModel):
"""信息卡片元数据项"""
label: str
value: str | int
class InfoCardSection(BaseModel):
"""信息卡片内容区块"""
title: str
content: list[str] = Field(..., description="内容段落列表")
class InfoCardData(RenderableComponent):
"""通用信息卡片的数据模型"""
style_name: str | None = None
title: str = Field(..., description="卡片主标题")
metadata: list[InfoCardMetadataItem] = Field(default_factory=list)
sections: list[InfoCardSection] = Field(default_factory=list)
@property
def template_name(self) -> str:
return "components/presets/info_card"

View File

@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent):
@property
def template_name(self) -> str:
return "pages/core/help_page"
return "pages/core/plugin_help_page"

View File

@ -3,12 +3,12 @@ from pathlib import Path
import random
from zhenxun import ui
from zhenxun.ui.models import BarChartData
from zhenxun.ui.builders import charts as chart_builders
from .models import Barh
BACKGROUND_PATH = (
Path() / "resources" / "themes" / "default" / "assets" / "bar_chart" / "background"
Path() / "resources" / "themes" / "default" / "assets" / "ui" / "background"
)
@ -21,12 +21,11 @@ class ChartUtils:
if BACKGROUND_PATH.exists()
else None
)
chart_component = BarChartData(
title=data.title,
category_data=data.category_data,
data=data.data,
background_image=background_image_name,
direction="horizontal",
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(chart_component)
return await ui.render(builder.build())

View File

@ -18,6 +18,7 @@ __all__ = [
"PYDANTIC_V2",
"_dump_pydantic_obj",
"_is_pydantic_type",
"compat_computed_field",
"model_copy",
"model_dump",
"model_json_schema",
@ -38,6 +39,12 @@ def model_copy(
return model.copy(update=update_dict, deep=deep)
if PYDANTIC_V2:
from pydantic import computed_field as compat_computed_field
else:
compat_computed_field = property
def model_json_schema(model_class: type[BaseModel], **kwargs: Any) -> dict[str, Any]:
"""
Pydantic `Model.schema()` (v1) `Model.model_json_schema()` (v2) 的兼容函数