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