From 7472cabd48bf0dd07824feaa6f56197cf7c04449 Mon Sep 17 00:00:00 2001 From: Rumio <32546670+webjoin111@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:20:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat!(ui):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E7=BB=84=E4=BB=B6=E6=9E=B6=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=95=B0=E6=8D=AE=E4=B8=8E=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=88=86=E7=A6=BB=20=20(#2035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 规则 * :rotating_light: 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> --- .gitignore | 1 + resources.spec | 1 + zhenxun/builtin_plugins/help/__init__.py | 5 +- zhenxun/builtin_plugins/help/_data_source.py | 40 +- .../builtin_plugins/llm_manager/presenters.py | 18 +- zhenxun/builtin_plugins/sign_in/utils.py | 49 +- .../builtin_plugins/superuser/ui_manager.py | 92 +++- zhenxun/services/renderer/config.py | 13 + zhenxun/services/renderer/models.py | 4 + zhenxun/services/renderer/protocols.py | 49 +- zhenxun/services/renderer/registry.py | 32 ++ zhenxun/services/renderer/service.py | 459 +++++++++++++---- zhenxun/services/renderer/theme.py | 483 +++++++++++++----- zhenxun/ui/__init__.py | 116 ++++- zhenxun/ui/builders/__init__.py | 50 +- zhenxun/ui/builders/base.py | 70 ++- zhenxun/ui/builders/charts.py | 211 +++++--- zhenxun/ui/builders/components/__init__.py | 25 + zhenxun/ui/builders/components/alert.py | 23 + zhenxun/ui/builders/components/avatar.py | 38 ++ .../builders/{widgets => components}/badge.py | 0 zhenxun/ui/builders/components/divider.py | 20 + zhenxun/ui/builders/components/kpi_card.py | 31 ++ .../{widgets => components}/progress_bar.py | 0 zhenxun/ui/builders/components/timeline.py | 28 + .../user_info_block.py | 0 zhenxun/ui/builders/core/__init__.py | 8 + zhenxun/ui/builders/core/card.py | 26 + zhenxun/ui/builders/core/details.py | 19 + zhenxun/ui/builders/core/layout.py | 56 +- zhenxun/ui/builders/core/list.py | 31 ++ zhenxun/ui/builders/core/markdown.py | 17 +- zhenxun/ui/builders/core/table.py | 53 +- zhenxun/ui/builders/core/text.py | 62 +++ zhenxun/ui/builders/presets/__init__.py | 4 +- zhenxun/ui/builders/presets/info_card.py | 46 -- .../{help_page.py => plugin_help_page.py} | 4 +- zhenxun/ui/builders/widgets/__init__.py | 14 - zhenxun/ui/models/__init__.py | 57 +-- zhenxun/ui/models/charts.py | 143 ++++-- zhenxun/ui/models/components/__init__.py | 7 + zhenxun/ui/models/components/alert.py | 23 + zhenxun/ui/models/components/avatar.py | 35 ++ zhenxun/ui/models/components/kpi_card.py | 29 ++ zhenxun/ui/models/components/timeline.py | 30 ++ zhenxun/ui/models/core/__init__.py | 11 + zhenxun/ui/models/core/base.py | 79 +-- zhenxun/ui/models/core/card.py | 24 + zhenxun/ui/models/core/details.py | 23 + zhenxun/ui/models/core/layout.py | 39 +- zhenxun/ui/models/core/list.py | 30 ++ zhenxun/ui/models/core/markdown.py | 59 ++- zhenxun/ui/models/core/notebook.py | 6 +- zhenxun/ui/models/core/table.py | 18 +- zhenxun/ui/models/core/template.py | 9 + zhenxun/ui/models/core/text.py | 32 ++ zhenxun/ui/models/presets/__init__.py | 6 +- zhenxun/ui/models/presets/card.py | 36 -- .../{help_page.py => plugin_help_page.py} | 2 +- zhenxun/utils/echart_utils/__init__.py | 17 +- zhenxun/utils/pydantic_compat.py | 7 + 61 files changed, 2272 insertions(+), 648 deletions(-) create mode 100644 resources.spec create mode 100644 zhenxun/services/renderer/config.py create mode 100644 zhenxun/services/renderer/registry.py create mode 100644 zhenxun/ui/builders/components/__init__.py create mode 100644 zhenxun/ui/builders/components/alert.py create mode 100644 zhenxun/ui/builders/components/avatar.py rename zhenxun/ui/builders/{widgets => components}/badge.py (100%) create mode 100644 zhenxun/ui/builders/components/divider.py create mode 100644 zhenxun/ui/builders/components/kpi_card.py rename zhenxun/ui/builders/{widgets => components}/progress_bar.py (100%) create mode 100644 zhenxun/ui/builders/components/timeline.py rename zhenxun/ui/builders/{widgets => components}/user_info_block.py (100%) create mode 100644 zhenxun/ui/builders/core/card.py create mode 100644 zhenxun/ui/builders/core/details.py create mode 100644 zhenxun/ui/builders/core/list.py create mode 100644 zhenxun/ui/builders/core/text.py delete mode 100644 zhenxun/ui/builders/presets/info_card.py rename zhenxun/ui/builders/presets/{help_page.py => plugin_help_page.py} (85%) delete mode 100644 zhenxun/ui/builders/widgets/__init__.py create mode 100644 zhenxun/ui/models/components/alert.py create mode 100644 zhenxun/ui/models/components/avatar.py create mode 100644 zhenxun/ui/models/components/kpi_card.py create mode 100644 zhenxun/ui/models/components/timeline.py create mode 100644 zhenxun/ui/models/core/card.py create mode 100644 zhenxun/ui/models/core/details.py create mode 100644 zhenxun/ui/models/core/list.py create mode 100644 zhenxun/ui/models/core/text.py delete mode 100644 zhenxun/ui/models/presets/card.py rename zhenxun/ui/models/presets/{help_page.py => plugin_help_page.py} (93%) diff --git a/.gitignore b/.gitignore index 24fa1ea6..6ec21f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/resources.spec b/resources.spec new file mode 100644 index 00000000..2f5910f3 --- /dev/null +++ b/resources.spec @@ -0,0 +1 @@ +require_resources_version: ">=1.0.0" diff --git a/zhenxun/builtin_plugins/help/__init__.py b/zhenxun/builtin_plugins/help/__init__.py index 0c483ac2..37dbc0ba 100644 --- a/zhenxun/builtin_plugins/help/__init__.py +++ b/zhenxun/builtin_plugins/help/__init__.py @@ -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 ( diff --git a/zhenxun/builtin_plugins/help/_data_source.py b/zhenxun/builtin_plugins/help/_data_source.py index f14b401c..f86aca8b 100644 --- a/zhenxun/builtin_plugins/help/_data_source.py +++ b/zhenxun/builtin_plugins/help/_data_source.py @@ -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(" ", " ") 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 "没有查找到这个功能噢..." diff --git a/zhenxun/builtin_plugins/llm_manager/presenters.py b/zhenxun/builtin_plugins/llm_manager/presenters.py index 590eef52..242466ce 100644 --- a/zhenxun/builtin_plugins/llm_manager/presenters.py +++ b/zhenxun/builtin_plugins/llm_manager/presenters.py @@ -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 ` 查看详情" ) 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 diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py index 794812ad..8ad4523c 100644 --- a/zhenxun/builtin_plugins/sign_in/utils.py +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -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 = { diff --git a/zhenxun/builtin_plugins/superuser/ui_manager.py b/zhenxun/builtin_plugins/superuser/ui_manager.py index d1b1ba89..3db567d0 100644 --- a/zhenxun/builtin_plugins/superuser/ui_manager.py +++ b/zhenxun/builtin_plugins/superuser/ui_manager.py @@ -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) diff --git a/zhenxun/services/renderer/config.py b/zhenxun/services/renderer/config.py new file mode 100644 index 00000000..6d2ebc2e --- /dev/null +++ b/zhenxun/services/renderer/config.py @@ -0,0 +1,13 @@ +""" +渲染器服务的共享配置和常量 +""" + +RESERVED_TEMPLATE_KEYS: set[str] = { + "data", + "theme", + "theme_css", + "extra_css", + "required_scripts", + "required_styles", + "frameless", +} diff --git a/zhenxun/services/renderer/models.py b/zhenxun/services/renderer/models.py index df4fa900..3ccfb2f9 100644 --- a/zhenxun/services/renderer/models.py +++ b/zhenxun/services/renderer/models.py @@ -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)" ) diff --git a/zhenxun/services/renderer/protocols.py b/zhenxun/services/renderer/protocols.py index 805ab571..3255b0d3 100644 --- a/zhenxun/services/renderer/protocols.py +++ b/zhenxun/services/renderer/protocols.py @@ -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"]: + """ + [新增] 返回一个包含所有直接子组件的可迭代对象。 + + 这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。 + 非容器组件应返回一个空列表。 + """ + ... + 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 diff --git a/zhenxun/services/renderer/registry.py b/zhenxun/services/renderer/registry.py new file mode 100644 index 00000000..26714c55 --- /dev/null +++ b/zhenxun/services/renderer/registry.py @@ -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() diff --git a/zhenxun/services/renderer/service.py b/zhenxun/services/renderer/service.py index 3bb56d29..8ff11010 100644 --- a/zhenxun/services/renderer/service.py +++ b/zhenxun/services/renderer/service.py @@ -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) diff --git a/zhenxun/services/renderer/theme.py b/zhenxun/services/renderer/theme.py index ead1b45c..b386e4c6 100644 --- a/zhenxun/services/renderer/theme.py +++ b/zhenxun/services/renderer/theme.py @@ -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"" + @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 diff --git a/zhenxun/ui/__init__.py b/zhenxun/ui/__init__.py index 876471bf..9546274c 100644 --- a/zhenxun/ui/__init__.py +++ b/zhenxun/ui/__init__.py @@ -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", +] diff --git a/zhenxun/ui/builders/__init__.py b/zhenxun/ui/builders/__init__.py index 5611282a..51d7fe37 100644 --- a/zhenxun/ui/builders/__init__.py +++ b/zhenxun/ui/builders/__init__.py @@ -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", ] diff --git a/zhenxun/ui/builders/base.py b/zhenxun/ui/builders/base.py index 8d47af62..c7a83817 100644 --- a/zhenxun/ui/builders/base.py +++ b/zhenxun/ui/builders/base.py @@ -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 diff --git a/zhenxun/ui/builders/charts.py b/zhenxun/ui/builders/charts.py index aca84133..fa4ad2f8 100644 --- a/zhenxun/ui/builders/charts.py +++ b/zhenxun/ui/builders/charts.py @@ -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 diff --git a/zhenxun/ui/builders/components/__init__.py b/zhenxun/ui/builders/components/__init__.py new file mode 100644 index 00000000..34f9fa2c --- /dev/null +++ b/zhenxun/ui/builders/components/__init__.py @@ -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", +] diff --git a/zhenxun/ui/builders/components/alert.py b/zhenxun/ui/builders/components/alert.py new file mode 100644 index 00000000..1f0b667f --- /dev/null +++ b/zhenxun/ui/builders/components/alert.py @@ -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 diff --git a/zhenxun/ui/builders/components/avatar.py b/zhenxun/ui/builders/components/avatar.py new file mode 100644 index 00000000..bf7995fa --- /dev/null +++ b/zhenxun/ui/builders/components/avatar.py @@ -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 diff --git a/zhenxun/ui/builders/widgets/badge.py b/zhenxun/ui/builders/components/badge.py similarity index 100% rename from zhenxun/ui/builders/widgets/badge.py rename to zhenxun/ui/builders/components/badge.py diff --git a/zhenxun/ui/builders/components/divider.py b/zhenxun/ui/builders/components/divider.py new file mode 100644 index 00000000..46aa4785 --- /dev/null +++ b/zhenxun/ui/builders/components/divider.py @@ -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") diff --git a/zhenxun/ui/builders/components/kpi_card.py b/zhenxun/ui/builders/components/kpi_card.py new file mode 100644 index 00000000..ee8f2871 --- /dev/null +++ b/zhenxun/ui/builders/components/kpi_card.py @@ -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 diff --git a/zhenxun/ui/builders/widgets/progress_bar.py b/zhenxun/ui/builders/components/progress_bar.py similarity index 100% rename from zhenxun/ui/builders/widgets/progress_bar.py rename to zhenxun/ui/builders/components/progress_bar.py diff --git a/zhenxun/ui/builders/components/timeline.py b/zhenxun/ui/builders/components/timeline.py new file mode 100644 index 00000000..b4fa00a7 --- /dev/null +++ b/zhenxun/ui/builders/components/timeline.py @@ -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 diff --git a/zhenxun/ui/builders/widgets/user_info_block.py b/zhenxun/ui/builders/components/user_info_block.py similarity index 100% rename from zhenxun/ui/builders/widgets/user_info_block.py rename to zhenxun/ui/builders/components/user_info_block.py diff --git a/zhenxun/ui/builders/core/__init__.py b/zhenxun/ui/builders/core/__init__.py index 50052b2a..c9df2ba4 100644 --- a/zhenxun/ui/builders/core/__init__.py +++ b/zhenxun/ui/builders/core/__init__.py @@ -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", ] diff --git a/zhenxun/ui/builders/core/card.py b/zhenxun/ui/builders/core/card.py new file mode 100644 index 00000000..83a902f8 --- /dev/null +++ b/zhenxun/ui/builders/core/card.py @@ -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 diff --git a/zhenxun/ui/builders/core/details.py b/zhenxun/ui/builders/core/details.py new file mode 100644 index 00000000..7bdb773a --- /dev/null +++ b/zhenxun/ui/builders/core/details.py @@ -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 diff --git a/zhenxun/ui/builders/core/layout.py b/zhenxun/ui/builders/core/layout.py index 7f9d8667..e63a700b 100644 --- a/zhenxun/ui/builders/core/layout.py +++ b/zhenxun/ui/builders/core/layout.py @@ -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() diff --git a/zhenxun/ui/builders/core/list.py b/zhenxun/ui/builders/core/list.py new file mode 100644 index 00000000..af03b845 --- /dev/null +++ b/zhenxun/ui/builders/core/list.py @@ -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 diff --git a/zhenxun/ui/builders/core/markdown.py b/zhenxun/ui/builders/core/markdown.py index c0e35e6e..a556cd84 100644 --- a/zhenxun/ui/builders/core/markdown.py +++ b/zhenxun/ui/builders/core/markdown.py @@ -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() diff --git a/zhenxun/ui/builders/core/table.py b/zhenxun/ui/builders/core/table.py index a0016635..5b996203 100644 --- a/zhenxun/ui/builders/core/table.py +++ b/zhenxun/ui/builders/core/table.py @@ -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 diff --git a/zhenxun/ui/builders/core/text.py b/zhenxun/ui/builders/core/text.py new file mode 100644 index 00000000..5ce24987 --- /dev/null +++ b/zhenxun/ui/builders/core/text.py @@ -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 diff --git a/zhenxun/ui/builders/presets/__init__.py b/zhenxun/ui/builders/presets/__init__.py index cb6b9ef6..f5fb6d90 100644 --- a/zhenxun/ui/builders/presets/__init__.py +++ b/zhenxun/ui/builders/presets/__init__.py @@ -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", ] diff --git a/zhenxun/ui/builders/presets/info_card.py b/zhenxun/ui/builders/presets/info_card.py deleted file mode 100644 index 8567448b..00000000 --- a/zhenxun/ui/builders/presets/info_card.py +++ /dev/null @@ -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 diff --git a/zhenxun/ui/builders/presets/help_page.py b/zhenxun/ui/builders/presets/plugin_help_page.py similarity index 85% rename from zhenxun/ui/builders/presets/help_page.py rename to zhenxun/ui/builders/presets/plugin_help_page.py index 51e4f5b8..e06b0a1c 100644 --- a/zhenxun/ui/builders/presets/help_page.py +++ b/zhenxun/ui/builders/presets/plugin_help_page.py @@ -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": """添加一个帮助分类""" diff --git a/zhenxun/ui/builders/widgets/__init__.py b/zhenxun/ui/builders/widgets/__init__.py deleted file mode 100644 index 9b915dc1..00000000 --- a/zhenxun/ui/builders/widgets/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -小组件构建器模块 -包含各种UI小组件的构建器 -""" - -from .badge import BadgeBuilder -from .progress_bar import ProgressBarBuilder -from .user_info_block import UserInfoBlockBuilder - -__all__ = [ - "BadgeBuilder", - "ProgressBarBuilder", - "UserInfoBlockBuilder", -] diff --git a/zhenxun/ui/models/__init__.py b/zhenxun/ui/models/__init__.py index 6d539eb5..280f0519 100644 --- a/zhenxun/ui/models/__init__.py +++ b/zhenxun/ui/models/__init__.py @@ -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", diff --git a/zhenxun/ui/models/charts.py b/zhenxun/ui/models/charts.py index 3b3cba16..e393bd7f 100644 --- a/zhenxun/ui/models/charts.py +++ b/zhenxun/ui/models/charts.py @@ -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 diff --git a/zhenxun/ui/models/components/__init__.py b/zhenxun/ui/models/components/__init__.py index 89adcac8..5cf25e0f 100644 --- a/zhenxun/ui/models/components/__init__.py +++ b/zhenxun/ui/models/components/__init__.py @@ -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", ] diff --git a/zhenxun/ui/models/components/alert.py b/zhenxun/ui/models/components/alert.py new file mode 100644 index 00000000..d205d6a7 --- /dev/null +++ b/zhenxun/ui/models/components/alert.py @@ -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" diff --git a/zhenxun/ui/models/components/avatar.py b/zhenxun/ui/models/components/avatar.py new file mode 100644 index 00000000..287cf1a7 --- /dev/null +++ b/zhenxun/ui/models/components/avatar.py @@ -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" diff --git a/zhenxun/ui/models/components/kpi_card.py b/zhenxun/ui/models/components/kpi_card.py new file mode 100644 index 00000000..a3bf8704 --- /dev/null +++ b/zhenxun/ui/models/components/kpi_card.py @@ -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" diff --git a/zhenxun/ui/models/components/timeline.py b/zhenxun/ui/models/components/timeline.py new file mode 100644 index 00000000..822313e0 --- /dev/null +++ b/zhenxun/ui/models/components/timeline.py @@ -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" diff --git a/zhenxun/ui/models/core/__init__.py b/zhenxun/ui/models/core/__init__.py index b999ce2e..513e902e 100644 --- a/zhenxun/ui/models/core/__init__.py +++ b/zhenxun/ui/models/core/__init__.py @@ -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", ] diff --git a/zhenxun/ui/models/core/base.py b/zhenxun/ui/models/core/base.py index 8df446fe..0858ee16 100644 --- a/zhenxun/ui/models/core/base.py +++ b/zhenxun/ui/models/core/base.py @@ -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) diff --git a/zhenxun/ui/models/core/card.py b/zhenxun/ui/models/core/card.py new file mode 100644 index 00000000..462ee6f6 --- /dev/null +++ b/zhenxun/ui/models/core/card.py @@ -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 diff --git a/zhenxun/ui/models/core/details.py b/zhenxun/ui/models/core/details.py new file mode 100644 index 00000000..de324234 --- /dev/null +++ b/zhenxun/ui/models/core/details.py @@ -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" diff --git a/zhenxun/ui/models/core/layout.py b/zhenxun/ui/models/core/layout.py index 67562934..0fb078a0 100644 --- a/zhenxun/ui/models/core/layout.py +++ b/zhenxun/ui/models/core/layout.py @@ -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 diff --git a/zhenxun/ui/models/core/list.py b/zhenxun/ui/models/core/list.py new file mode 100644 index 00000000..97bd7b2c --- /dev/null +++ b/zhenxun/ui/models/core/list.py @@ -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 diff --git a/zhenxun/ui/models/core/markdown.py b/zhenxun/ui/models/core/markdown.py index 346c4174..614ccf52 100644 --- a/zhenxun/ui/models/core/markdown.py +++ b/zhenxun/ui/models/core/markdown.py @@ -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 "" diff --git a/zhenxun/ui/models/core/notebook.py b/zhenxun/ui/models/core/notebook.py index 3ff09958..2038cc17 100644 --- a/zhenxun/ui/models/core/notebook.py +++ b/zhenxun/ui/models/core/notebook.py @@ -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 diff --git a/zhenxun/ui/models/core/table.py b/zhenxun/ui/models/core/table.py index 07a5519a..c905256a 100644 --- a/zhenxun/ui/models/core/table.py +++ b/zhenxun/ui/models/core/table.py @@ -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: diff --git a/zhenxun/ui/models/core/template.py b/zhenxun/ui/models/core/template.py index b1bc4311..62723a4e 100644 --- a/zhenxun/ui/models/core/template.py +++ b/zhenxun/ui/models/core/template.py @@ -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 diff --git a/zhenxun/ui/models/core/text.py b/zhenxun/ui/models/core/text.py new file mode 100644 index 00000000..2647d035 --- /dev/null +++ b/zhenxun/ui/models/core/text.py @@ -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" diff --git a/zhenxun/ui/models/presets/__init__.py b/zhenxun/ui/models/presets/__init__.py index de259e50..ef55c520 100644 --- a/zhenxun/ui/models/presets/__init__.py +++ b/zhenxun/ui/models/presets/__init__.py @@ -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", diff --git a/zhenxun/ui/models/presets/card.py b/zhenxun/ui/models/presets/card.py deleted file mode 100644 index 55f5f634..00000000 --- a/zhenxun/ui/models/presets/card.py +++ /dev/null @@ -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" diff --git a/zhenxun/ui/models/presets/help_page.py b/zhenxun/ui/models/presets/plugin_help_page.py similarity index 93% rename from zhenxun/ui/models/presets/help_page.py rename to zhenxun/ui/models/presets/plugin_help_page.py index 8a2c9215..dfc013ab 100644 --- a/zhenxun/ui/models/presets/help_page.py +++ b/zhenxun/ui/models/presets/plugin_help_page.py @@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent): @property def template_name(self) -> str: - return "pages/core/help_page" + return "pages/core/plugin_help_page" diff --git a/zhenxun/utils/echart_utils/__init__.py b/zhenxun/utils/echart_utils/__init__.py index d7576826..2ef229cc 100644 --- a/zhenxun/utils/echart_utils/__init__.py +++ b/zhenxun/utils/echart_utils/__init__.py @@ -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()) diff --git a/zhenxun/utils/pydantic_compat.py b/zhenxun/utils/pydantic_compat.py index 13ba1196..2235f9ac 100644 --- a/zhenxun/utils/pydantic_compat.py +++ b/zhenxun/utils/pydantic_compat.py @@ -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) 的兼容函数。