From 11524bcb04e24cf9626bc4b2eb8e67f3813f37a8 Mon Sep 17 00:00:00 2001 From: Rumio <32546670+webjoin111@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:34:37 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=9B=BE=E7=89=87=E6=B8=B2=E6=9F=93=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=B9=B6=E5=BC=95=E5=85=A5=E9=80=9A=E7=94=A8UI=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=B3=BB=E7=BB=9F=20(#2019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: 统一图片渲染架构并引入通用UI组件系统 🎨 **渲染服务重构** - 统一图片渲染入口,引入主题系统支持 - 优化Jinja2环境管理,支持主题覆盖和插件命名空间 - 新增UI缓存机制和主题重载功能 ✨ **通用UI组件系统** - 新增 zhenxun.ui 模块,提供数据模型和构建器 - 引入BaseBuilder基类,支持链式调用 - 新增多种UI构建器:InfoCard, Markdown, Table, Chart, Layout等 - 新增通用组件:Divider, Badge, ProgressBar, UserInfoBlock 🔄 **插件迁移** - 迁移9个内置插件至新渲染系统 - 移除各插件中分散的图片生成工具 - 优化数据处理和渲染逻辑 💥 **Breaking Changes** - 移除旧的图片渲染接口和模板路径 - TEMPLATE_PATH 更名为 THEMES_PATH - 插件需适配新的RendererService和zhenxun.ui模块 * ✅ test(check): 更新自检插件测试中的渲染服务模拟 * ♻️ refactor(renderer): 将缓存文件名哈希算法切换到 SHA256 * ♻️ refactor(shop): 移除商店HTML图片生成模块 * :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> --- tests/builtin_plugins/check/test_check.py | 35 +- .../{admin_help/__init__.py => admin_help.py} | 42 +- .../admin/admin_help/config.py | 23 - .../admin/admin_help/html_help.py | 57 -- .../admin/admin_help/normal_help.py | 127 ----- .../builtin_plugins/admin/admin_help/utils.py | 22 - .../chat_history/chat_message_handle.py | 79 ++- zhenxun/builtin_plugins/check/__init__.py | 20 +- zhenxun/builtin_plugins/help/__init__.py | 39 +- zhenxun/builtin_plugins/help/_data_source.py | 259 +++++----- zhenxun/builtin_plugins/help/_utils.py | 2 +- zhenxun/builtin_plugins/help/detail_help.py | 0 zhenxun/builtin_plugins/help/html_help.py | 150 ------ zhenxun/builtin_plugins/help/normal_help.py | 100 ---- zhenxun/builtin_plugins/help/zhenxun_help.py | 143 ----- zhenxun/builtin_plugins/info/my_info.py | 119 +++-- .../builtin_plugins/llm_manager/presenters.py | 176 +++---- .../builtin_plugins/mahiro_bank/__init__.py | 27 +- .../mahiro_bank/data_source.py | 63 +-- zhenxun/builtin_plugins/shop/_data_source.py | 72 ++- zhenxun/builtin_plugins/shop/config.py | 4 +- zhenxun/builtin_plugins/shop/html_image.py | 89 ---- zhenxun/builtin_plugins/shop/normal_image.py | 207 -------- zhenxun/builtin_plugins/sign_in/__init__.py | 6 - zhenxun/builtin_plugins/sign_in/config.py | 6 - zhenxun/builtin_plugins/sign_in/utils.py | 453 +++++----------- .../superuser/reload_setting.py | 4 + .../{super_help/__init__.py => super_help.py} | 43 +- .../superuser/super_help/config.py | 23 - .../superuser/super_help/normal_help.py | 127 ----- .../superuser/super_help/utils.py | 22 - .../superuser/super_help/zhenxun_help.py | 60 --- zhenxun/configs/path_config.py | 5 +- zhenxun/services/__init__.py | 2 + zhenxun/services/help_service.py | 109 ++++ zhenxun/services/renderer/__init__.py | 38 ++ zhenxun/services/renderer/engines.py | 221 ++++++++ zhenxun/services/renderer/models.py | 40 ++ zhenxun/services/renderer/service.py | 489 ++++++++++++++++++ zhenxun/ui/__init__.py | 40 ++ zhenxun/ui/builders/__init__.py | 19 + zhenxun/ui/builders/base.py | 41 ++ zhenxun/ui/builders/charts.py | 88 ++++ zhenxun/ui/builders/core/__init__.py | 16 + zhenxun/ui/builders/core/layout.py | 117 +++++ zhenxun/ui/builders/core/markdown.py | 149 ++++++ zhenxun/ui/builders/core/notebook.py | 107 ++++ zhenxun/ui/builders/core/table.py | 27 + zhenxun/ui/builders/presets/__init__.py | 14 + zhenxun/ui/builders/presets/help_page.py | 27 + zhenxun/ui/builders/presets/info_card.py | 46 ++ zhenxun/ui/builders/presets/plugin_menu.py | 36 ++ zhenxun/ui/builders/widgets/__init__.py | 14 + zhenxun/ui/builders/widgets/badge.py | 25 + zhenxun/ui/builders/widgets/progress_bar.py | 42 ++ .../ui/builders/widgets/user_info_block.py | 33 ++ zhenxun/ui/models/__init__.py | 84 +++ zhenxun/ui/models/charts.py | 43 ++ zhenxun/ui/models/components/__init__.py | 17 + zhenxun/ui/models/components/badge.py | 22 + zhenxun/ui/models/components/divider.py | 35 ++ zhenxun/ui/models/components/progress_bar.py | 24 + .../ui/models/components/user_info_block.py | 23 + zhenxun/ui/models/core/__init__.py | 47 ++ zhenxun/ui/models/core/base.py | 20 + zhenxun/ui/models/core/layout.py | 24 + zhenxun/ui/models/core/markdown.py | 124 +++++ zhenxun/ui/models/core/notebook.py | 38 ++ zhenxun/ui/models/core/table.py | 59 +++ zhenxun/ui/models/presets/__init__.py | 20 + zhenxun/ui/models/presets/card.py | 37 ++ zhenxun/ui/models/presets/help_page.py | 38 ++ zhenxun/ui/models/presets/plugin_menu.py | 42 ++ zhenxun/utils/_image_template.py | 191 ------- zhenxun/utils/common_utils.py | 15 + zhenxun/utils/echart_utils/__init__.py | 35 +- zhenxun/utils/exception.py | 8 + zhenxun/utils/manager/bot_profile_manager.py | 62 +-- 78 files changed, 3130 insertions(+), 2222 deletions(-) rename zhenxun/builtin_plugins/admin/{admin_help/__init__.py => admin_help.py} (60%) delete mode 100644 zhenxun/builtin_plugins/admin/admin_help/config.py delete mode 100644 zhenxun/builtin_plugins/admin/admin_help/html_help.py delete mode 100644 zhenxun/builtin_plugins/admin/admin_help/normal_help.py delete mode 100644 zhenxun/builtin_plugins/admin/admin_help/utils.py delete mode 100644 zhenxun/builtin_plugins/help/detail_help.py delete mode 100644 zhenxun/builtin_plugins/help/html_help.py delete mode 100644 zhenxun/builtin_plugins/help/normal_help.py delete mode 100644 zhenxun/builtin_plugins/help/zhenxun_help.py delete mode 100644 zhenxun/builtin_plugins/shop/html_image.py delete mode 100644 zhenxun/builtin_plugins/shop/normal_image.py rename zhenxun/builtin_plugins/superuser/{super_help/__init__.py => super_help.py} (50%) delete mode 100644 zhenxun/builtin_plugins/superuser/super_help/config.py delete mode 100644 zhenxun/builtin_plugins/superuser/super_help/normal_help.py delete mode 100644 zhenxun/builtin_plugins/superuser/super_help/utils.py delete mode 100644 zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py create mode 100644 zhenxun/services/help_service.py create mode 100644 zhenxun/services/renderer/__init__.py create mode 100644 zhenxun/services/renderer/engines.py create mode 100644 zhenxun/services/renderer/models.py create mode 100644 zhenxun/services/renderer/service.py create mode 100644 zhenxun/ui/__init__.py create mode 100644 zhenxun/ui/builders/__init__.py create mode 100644 zhenxun/ui/builders/base.py create mode 100644 zhenxun/ui/builders/charts.py create mode 100644 zhenxun/ui/builders/core/__init__.py create mode 100644 zhenxun/ui/builders/core/layout.py create mode 100644 zhenxun/ui/builders/core/markdown.py create mode 100644 zhenxun/ui/builders/core/notebook.py create mode 100644 zhenxun/ui/builders/core/table.py create mode 100644 zhenxun/ui/builders/presets/__init__.py create mode 100644 zhenxun/ui/builders/presets/help_page.py create mode 100644 zhenxun/ui/builders/presets/info_card.py create mode 100644 zhenxun/ui/builders/presets/plugin_menu.py create mode 100644 zhenxun/ui/builders/widgets/__init__.py create mode 100644 zhenxun/ui/builders/widgets/badge.py create mode 100644 zhenxun/ui/builders/widgets/progress_bar.py create mode 100644 zhenxun/ui/builders/widgets/user_info_block.py create mode 100644 zhenxun/ui/models/__init__.py create mode 100644 zhenxun/ui/models/charts.py create mode 100644 zhenxun/ui/models/components/__init__.py create mode 100644 zhenxun/ui/models/components/badge.py create mode 100644 zhenxun/ui/models/components/divider.py create mode 100644 zhenxun/ui/models/components/progress_bar.py create mode 100644 zhenxun/ui/models/components/user_info_block.py create mode 100644 zhenxun/ui/models/core/__init__.py create mode 100644 zhenxun/ui/models/core/base.py create mode 100644 zhenxun/ui/models/core/layout.py create mode 100644 zhenxun/ui/models/core/markdown.py create mode 100644 zhenxun/ui/models/core/notebook.py create mode 100644 zhenxun/ui/models/core/table.py create mode 100644 zhenxun/ui/models/presets/__init__.py create mode 100644 zhenxun/ui/models/presets/card.py create mode 100644 zhenxun/ui/models/presets/help_page.py create mode 100644 zhenxun/ui/models/presets/plugin_menu.py diff --git a/tests/builtin_plugins/check/test_check.py b/tests/builtin_plugins/check/test_check.py index 2ce765ac..0dddec67 100644 --- a/tests/builtin_plugins/check/test_check.py +++ b/tests/builtin_plugins/check/test_check.py @@ -65,9 +65,11 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path): mock_platform = mocker.patch("zhenxun.builtin_plugins.check.data_source.platform") mock_platform.uname.return_value = platform_uname - mock_template_to_pic = mocker.patch("zhenxun.builtin_plugins.check.template_to_pic") - mock_template_to_pic_return = mocker.AsyncMock() - mock_template_to_pic.return_value = mock_template_to_pic_return + mock_render_service = mocker.patch( + "zhenxun.builtin_plugins.check.renderer_service.render" + ) + mock_render_service_return = mocker.AsyncMock() + mock_render_service.return_value = mock_render_service_return mock_build_message = mocker.patch( "zhenxun.builtin_plugins.check.MessageUtils.build_message" @@ -75,19 +77,14 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path): mock_build_message_return = mocker.AsyncMock() mock_build_message.return_value = mock_build_message_return - mock_template_path_new = tmp_path / "resources" / "template" - mocker.patch( - "zhenxun.builtin_plugins.check.TEMPLATE_PATH", new=mock_template_path_new - ) return ( mock_psutil, mock_cpuinfo, mock_platform, - mock_template_to_pic, - mock_template_to_pic_return, + mock_render_service, + mock_render_service_return, mock_build_message, mock_build_message_return, - mock_template_path_new, ) @@ -107,11 +104,10 @@ async def test_check( mock_psutil, mock_cpuinfo, mock_platform, - mock_template_to_pic, - mock_template_to_pic_return, + mock_render_service, + mock_render_service_return, mock_build_message, mock_build_message_return, - mock_template_path_new, ) = init_mocker(mocker, tmp_path) async with app.test_matcher(_self_check_matcher) as ctx: bot = create_bot(ctx) @@ -128,8 +124,8 @@ async def test_check( ctx.receive_event(bot=bot, event=event) ctx.should_ignore_rule(_self_check_matcher) - mock_template_to_pic.assert_awaited_once() - mock_build_message.assert_called_once_with(mock_template_to_pic_return) + mock_render_service.assert_awaited_once() + mock_build_message.assert_called_once_with(mock_render_service_return) mock_build_message_return.send.assert_awaited_once() @@ -164,11 +160,10 @@ async def test_check_arm( mock_psutil, mock_cpuinfo, mock_platform, - mock_template_to_pic, - mock_template_to_pic_return, + mock_render_service, + mock_render_service_return, mock_build_message, mock_build_message_return, - mock_template_path_new, ) = init_mocker(mocker, tmp_path) mock_platform.uname.return_value = platform_uname_arm @@ -202,6 +197,6 @@ async def test_check_arm( mocker.call().decode().split().__getitem__().__float__(), ] # type: ignore ) - mock_template_to_pic.assert_awaited_once() - mock_build_message.assert_called_once_with(mock_template_to_pic_return) + mock_render_service.assert_awaited_once() + mock_build_message.assert_called_once_with(mock_render_service_return) mock_build_message_return.send.assert_awaited_once() diff --git a/zhenxun/builtin_plugins/admin/admin_help/__init__.py b/zhenxun/builtin_plugins/admin/admin_help.py similarity index 60% rename from zhenxun/builtin_plugins/admin/admin_help/__init__.py rename to zhenxun/builtin_plugins/admin/admin_help.py index 33f94fa4..0ddd466a 100644 --- a/zhenxun/builtin_plugins/admin/admin_help/__init__.py +++ b/zhenxun/builtin_plugins/admin/admin_help.py @@ -2,18 +2,14 @@ from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import Alconna, Arparma, on_alconna from nonebot_plugin_session import EventSession -from zhenxun.configs.config import Config -from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.help_service import create_plugin_help_image from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.exception import EmptyError from zhenxun.utils.message import MessageUtils from zhenxun.utils.rules import admin_check, ensure_group -from .config import ADMIN_HELP_IMAGE -from .html_help import build_html_help -from .normal_help import build_help - __plugin_meta__ = PluginMetadata( name="群组管理员帮助", description="管理员帮助列表", @@ -30,17 +26,19 @@ __plugin_meta__ = PluginMetadata( precautions=[ "只有群主/群管理 才能使用哦,群主拥有6级权限,管理员拥有5级权限!" ], - configs=[ - RegisterConfig( - key="type", - value="zhenxun", - help="管理员帮助样式,normal, zhenxun", - default_value="zhenxun", - ) - ], + configs=[], ).to_dict(), ) + +async def build_html_help() -> bytes: + """构建管理员帮助图片""" + return await create_plugin_help_image( + plugin_types=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN], + page_title="群管理员帮助手册", + ) + + _matcher = on_alconna( Alconna("管理员帮助"), rule=admin_check(1) & ensure_group, @@ -54,15 +52,9 @@ async def _( session: EventSession, arparma: Arparma, ): - if not ADMIN_HELP_IMAGE.exists(): - try: - if Config.get_config("admin_help", "type") == "zhenxun": - await build_html_help() - else: - await build_help() - except EmptyError: - await MessageUtils.build_message("当前管理员帮助为空...").finish( - reply_to=True - ) - await MessageUtils.build_message(ADMIN_HELP_IMAGE).send() + try: + image_bytes = await build_html_help() + await MessageUtils.build_message(image_bytes).send() + except EmptyError: + await MessageUtils.build_message("当前管理员帮助为空...").finish(reply_to=True) logger.info("查看管理员帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/admin/admin_help/config.py b/zhenxun/builtin_plugins/admin/admin_help/config.py deleted file mode 100644 index 86a12daa..00000000 --- a/zhenxun/builtin_plugins/admin/admin_help/config.py +++ /dev/null @@ -1,23 +0,0 @@ -from nonebot.plugin import PluginMetadata -from pydantic import BaseModel - -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.models.plugin_info import PluginInfo - -ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png" -if ADMIN_HELP_IMAGE.exists(): - ADMIN_HELP_IMAGE.unlink() - - -class PluginData(BaseModel): - """ - 插件信息 - """ - - plugin: PluginInfo - """插件信息""" - metadata: PluginMetadata - """元数据""" - - class Config: - arbitrary_types_allowed = True diff --git a/zhenxun/builtin_plugins/admin/admin_help/html_help.py b/zhenxun/builtin_plugins/admin/admin_help/html_help.py deleted file mode 100644 index 6fecf1dd..00000000 --- a/zhenxun/builtin_plugins/admin/admin_help/html_help.py +++ /dev/null @@ -1,57 +0,0 @@ -from nonebot_plugin_htmlrender import template_to_pic - -from zhenxun.builtin_plugins.admin.admin_help.config import ADMIN_HELP_IMAGE -from zhenxun.configs.config import BotConfig -from zhenxun.configs.path_config import TEMPLATE_PATH -from zhenxun.models.task_info import TaskInfo -from zhenxun.utils._build_image import BuildImage - -from .utils import get_plugins - - -async def get_task() -> dict[str, str] | None: - """获取被动技能帮助""" - if task_list := await TaskInfo.all(): - return { - "name": "被动技能", - "description": "控制群组中的被动技能状态", - "usage": "通过 开启/关闭群被动 来控制群被动
" - + " 示例:开启/关闭群被动早晚安
示例:开启/关闭全部群被动" - + "
----------
" - + "
".join([task.name for task in task_list]), - } - return None - - -async def build_html_help(): - """构建帮助图片""" - plugins = await get_plugins() - plugin_list = [ - { - "name": data.plugin.name, - "description": data.metadata.description.replace("\n", "
"), - "usage": data.metadata.usage.replace("\n", "
"), - } - for data in plugins - ] - if task := await get_task(): - plugin_list.append(task) - plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"])) - pic = await template_to_pic( - template_path=str((TEMPLATE_PATH / "help").absolute()), - template_name="main.html", - templates={ - "data": { - "plugin_list": plugin_list, - "nickname": BotConfig.self_nickname, - "help_name": "群管理员", - } - }, - pages={ - "viewport": {"width": 824, "height": 10}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - result = await BuildImage.open(pic).resize(0.5) - await result.save(ADMIN_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/admin/admin_help/normal_help.py b/zhenxun/builtin_plugins/admin/admin_help/normal_help.py deleted file mode 100644 index a677ba1b..00000000 --- a/zhenxun/builtin_plugins/admin/admin_help/normal_help.py +++ /dev/null @@ -1,127 +0,0 @@ -from nonebot.plugin import PluginMetadata -from PIL.ImageFont import FreeTypeFont - -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.models.task_info import TaskInfo -from zhenxun.services.log import logger -from zhenxun.utils._build_image import BuildImage -from zhenxun.utils.image_utils import build_sort_image, group_image, text2image - -from .config import ADMIN_HELP_IMAGE -from .utils import get_plugins - - -async def build_usage_des_image( - metadata: PluginMetadata, -) -> tuple[BuildImage | None, BuildImage | None]: - """构建用法和描述图片 - - 参数: - metadata: PluginMetadata - - 返回: - tuple[BuildImage | None, BuildImage | None]: 用法和描述图片 - """ - usage = None - description = None - if metadata.usage: - usage = await text2image( - metadata.usage, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - if metadata.description: - description = await text2image( - metadata.description, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - return usage, description - - -async def build_image( - plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont -) -> BuildImage: - """构建帮助图片 - - 参数: - plugin: PluginInfo - metadata: PluginMetadata - font: FreeTypeFont - - 返回: - BuildImage: 帮助图片 - - """ - usage, description = await build_usage_des_image(metadata) - width = 0 - height = 100 - if usage: - width = usage.width - height += usage.height - if description and description.width > width: - width = description.width - height += description.height - font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font) - if font_width > width: - width = font_width - A = BuildImage(width + 30, height + 120, "#EAEDF2") - await A.text((15, 10), f"{plugin.name}[{plugin.level}]") - await A.text((15, 70), "简介:") - if not description: - description = BuildImage(A.width - 30, 30, (255, 255, 255)) - await description.circle_corner(10) - await A.paste(description, (15, 100)) - if not usage: - usage = BuildImage(A.width - 30, 30, (255, 255, 255)) - await usage.circle_corner(10) - await A.text((15, description.height + 115), "用法:") - await A.paste(usage, (15, description.height + 145)) - await A.circle_corner(10) - return A - - -async def build_help(): - """构造管理员帮助图片 - - 返回: - BuildImage: 管理员帮助图片 - """ - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - image_list = [] - for data in await get_plugins(): - plugin = data.plugin - metadata = data.metadata - try: - A = await build_image(plugin, metadata, font) - image_list.append(A) - except Exception as e: - logger.warning( - f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...", - "管理员帮助", - e=e, - ) - if task_list := await TaskInfo.all(): - task_str = "\n".join([task.name for task in task_list]) - task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str - task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) - await task_image.circle_corner(10) - A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") - await A.text((25, 10), "被动技能") - await A.paste(task_image, (25, 50)) - await A.circle_corner(10) - image_list.append(A) - image_group, _ = group_image(image_list) - A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) - text = await BuildImage.build_text_image( - "群管理员帮助", - size=40, - ) - tip = await BuildImage.build_text_image( - "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" - ) - await A.paste(text, (50, 30)) - await A.paste(tip, (50, 90)) - await A.save(ADMIN_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/admin/admin_help/utils.py b/zhenxun/builtin_plugins/admin/admin_help/utils.py deleted file mode 100644 index 2385238e..00000000 --- a/zhenxun/builtin_plugins/admin/admin_help/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import nonebot - -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.utils.enum import PluginType -from zhenxun.utils.exception import EmptyError - -from .config import PluginData - - -async def get_plugins() -> list[PluginData]: - """获取插件数据""" - plugin_list = await PluginInfo.filter( - plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN] - ).all() - data_list = [] - for plugin in plugin_list: - if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): - if _plugin.metadata: - data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata)) - if not data_list: - raise EmptyError() - return data_list diff --git a/zhenxun/builtin_plugins/chat_history/chat_message_handle.py b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py index d9eae97f..a535bbd2 100644 --- a/zhenxun/builtin_plugins/chat_history/chat_message_handle.py +++ b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from io import BytesIO from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import ( @@ -20,8 +19,9 @@ from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig from zhenxun.models.chat_history import ChatHistory from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.services.log import logger +from zhenxun.ui import TableBuilder +from zhenxun.ui.models import ImageCell, TextCell from zhenxun.utils.enum import PluginType -from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.message import MessageUtils from zhenxun.utils.platform import PlatformUtils @@ -123,64 +123,61 @@ async def _( if rank_data := await ChatHistory.get_group_msg_rank( group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope ): - idx = 1 - data_list = [] + rows_data = [] + platform = "qq" - for uid, num in rank_data: - if len(data_list) >= count.result: + user_ids_in_rank = [str(uid) for uid, _ in rank_data] + users_in_group_query = GroupInfoUser.filter( + user_id__in=user_ids_in_rank, group_id=group_id + ) + users_in_group = {u.user_id: u for u in await users_in_group_query} + + for idx, (uid, num) in enumerate(rank_data): + if len(rows_data) >= count.result: break - user_in_group = await GroupInfoUser.filter( - user_id=uid, group_id=group_id - ).first() + uid_str = str(uid) + user_in_group = users_in_group.get(uid_str) if not user_in_group and not show_quit_member: continue - if user_in_group: - user_name = user_in_group.user_name - else: - user_name = f"{uid}(已退群)" + user_name = ( + user_in_group.user_name if user_in_group else f"{uid_str}(已退群)" + ) - avatar_size = 40 - try: - avatar_bytes = await PlatformUtils.get_user_avatar(str(uid), "qq") - if avatar_bytes: - avatar_img = BuildImage( - avatar_size, avatar_size, background=BytesIO(avatar_bytes) - ) - await avatar_img.circle() - avatar_tuple = (avatar_img, avatar_size, avatar_size) - else: - avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC") - await avatar_img.circle() - avatar_tuple = (avatar_img, avatar_size, avatar_size) - except Exception as e: - logger.warning(f"获取用户头像失败: {e}", "chat_history") - avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC") - await avatar_img.circle() - avatar_tuple = (avatar_img, avatar_size, avatar_size) + avatar_url = PlatformUtils.get_user_avatar_url(uid_str, platform) - data_list.append([idx, avatar_tuple, user_name, num]) - idx += 1 + rows_data.append( + [ + TextCell(content=str(len(rows_data) + 1)), + ImageCell(src=avatar_url or "", shape="circle"), + TextCell(content=user_name), + TextCell(content=str(num), bold=True), + ] + ) if not date_scope: - if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id): - date_scope = date_scope.astimezone( + first_msg_time = await ChatHistory.get_group_first_msg_datetime(group_id) + if first_msg_time: + date_scope_start = first_msg_time.astimezone( pytz.timezone("Asia/Shanghai") ).replace(microsecond=0) + date_str = f"{str(date_scope_start).split('+')[0]} - 至今" else: - date_scope = time_now.replace(microsecond=0) - date_str = f"{str(date_scope).split('+')[0]} - 至今" + date_str = f"{time_now.replace(microsecond=0)} - 至今" else: date_str = ( f"{date_scope[0].replace(microsecond=0)} - " f"{date_scope[1].replace(microsecond=0)}" ) - A = await ImageTemplate.table_page( - f"消息排行({count.result})", date_str, column_name, data_list - ) + + builder = TableBuilder(f"消息排行({count.result})", date_str) + builder.set_headers(column_name).add_rows(rows_data) + + image_bytes = await builder.build() + logger.info( f"查看消息排行 数量={count.result}", arparma.header_result, session=session ) - await MessageUtils.build_message(A).finish(reply_to=True) + await MessageUtils.build_message(image_bytes).finish(reply_to=True) await MessageUtils.build_message("群组消息记录为空...").finish() diff --git a/zhenxun/builtin_plugins/check/__init__.py b/zhenxun/builtin_plugins/check/__init__.py index 35122e44..2f4e1e1d 100644 --- a/zhenxun/builtin_plugins/check/__init__.py +++ b/zhenxun/builtin_plugins/check/__init__.py @@ -4,12 +4,11 @@ from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata from nonebot.rule import Rule, to_me from nonebot_plugin_alconna import Alconna, on_alconna -from nonebot_plugin_htmlrender import template_to_pic from zhenxun.configs.config import Config -from zhenxun.configs.path_config import TEMPLATE_PATH from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger +from zhenxun.services.renderer import renderer_service from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils from zhenxun.utils.rules import notice_rule @@ -67,18 +66,13 @@ _self_check_poke_matcher = on_notice( async def handle_self_check(): try: - data = await get_status_info() - image = await template_to_pic( - template_path=str((TEMPLATE_PATH / "check").absolute()), - template_name="main.html", - templates={"data": data}, - pages={ - "viewport": {"width": 195, "height": 750}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, + data_dict = await get_status_info() + + image_bytes = await renderer_service.render( + "pages/builtin/check", data=data_dict ) - await MessageUtils.build_message(image).send() + + await MessageUtils.build_message(image_bytes).send() logger.info("自检成功", "自检") except Exception as e: await MessageUtils.build_message(f"自检失败: {e}").send() diff --git a/zhenxun/builtin_plugins/help/__init__.py b/zhenxun/builtin_plugins/help/__init__.py index 0fa6f787..0c483ac2 100644 --- a/zhenxun/builtin_plugins/help/__init__.py +++ b/zhenxun/builtin_plugins/help/__init__.py @@ -13,11 +13,6 @@ from nonebot_plugin_alconna import ( ) from nonebot_plugin_uninfo import Uninfo -from zhenxun.builtin_plugins.help._config import ( - GROUP_HELP_PATH, - SIMPLE_DETAIL_HELP_IMAGE, - SIMPLE_HELP_IMAGE, -) from zhenxun.configs.config import Config from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger @@ -36,18 +31,6 @@ __plugin_meta__ = PluginMetadata( plugin_type=PluginType.DEPENDANT, is_show=False, configs=[ - RegisterConfig( - key="type", - value="zhenxun", - help="帮助图片样式 [normal, HTML, zhenxun]", - default_value="zhenxun", - ), - RegisterConfig( - key="detail_type", - value="zhenxun", - help="帮助详情图片样式 ['normal', 'zhenxun']", - default_value="zhenxun", - ), RegisterConfig( key="ENABLE_LLM_HELPER", value=False, @@ -76,6 +59,13 @@ __plugin_meta__ = PluginMetadata( default_value=100, type=int, ), + RegisterConfig( + key="HELP_STYLE", + value="default", + help="帮助页面的显示样式 (可选值: 'default', 'simple')", + default_value="default", + type=str, + ), ], ).to_dict(), ) @@ -144,15 +134,8 @@ async def _( f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session ) elif session.group and (gid := session.group.id): - _image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png" - if not _image_path.exists(): - await create_help_img(session, gid, is_detail.result) - await MessageUtils.build_message(_image_path).finish() + image_bytes = await create_help_img(session, gid, is_detail.result) + await MessageUtils.build_message(image_bytes).finish() else: - if is_detail.result: - _image_path = SIMPLE_DETAIL_HELP_IMAGE - else: - _image_path = SIMPLE_HELP_IMAGE - if not _image_path.exists(): - await create_help_img(session, None, is_detail.result) - await MessageUtils.build_message(_image_path).finish() + image_bytes = await create_help_img(session, None, is_detail.result) + await MessageUtils.build_message(image_bytes).finish() diff --git a/zhenxun/builtin_plugins/help/_data_source.py b/zhenxun/builtin_plugins/help/_data_source.py index c84ae09d..f908054e 100644 --- a/zhenxun/builtin_plugins/help/_data_source.py +++ b/zhenxun/builtin_plugins/help/_data_source.py @@ -1,13 +1,11 @@ -from pathlib import Path - import nonebot -from nonebot.plugin import PluginMetadata -from nonebot_plugin_htmlrender import template_to_pic from nonebot_plugin_uninfo import Uninfo -from zhenxun.configs.config import Config -from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH +from zhenxun.configs.config import BotConfig, Config +from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.configs.utils import PluginExtraData +from zhenxun.models.bot_console import BotConsole +from zhenxun.models.group_console import GroupConsole from zhenxun.models.level_user import LevelUser from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.statistics import Statistics @@ -15,60 +13,114 @@ from zhenxun.services import ( LLMException, LLMMessage, generate, + renderer_service, ) from zhenxun.services.log import logger -from zhenxun.utils._image_template import Markdown -from zhenxun.utils.enum import PluginType -from zhenxun.utils.image_utils import BuildImage, ImageTemplate - -from ._config import ( - GROUP_HELP_PATH, - SIMPLE_DETAIL_HELP_IMAGE, - SIMPLE_HELP_IMAGE, - base_config, +from zhenxun.ui import ( + InfoCardBuilder, + NotebookBuilder, + PluginMenuBuilder, + PluginMenuCategory, ) -from .html_help import build_html_image -from .normal_help import build_normal_image -from .zhenxun_help import build_zhenxun_image +from zhenxun.utils.common_utils import format_usage_for_markdown +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.utils.platform import PlatformUtils +from zhenxun.utils.pydantic_compat import model_dump + +from ._utils import classify_plugin random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help" - background = IMAGE_PATH / "background" / "0.png" - driver = nonebot.get_driver() +def _create_plugin_menu_item( + bot: BotConsole | None, + plugin: PluginInfo, + group: GroupConsole | None, + is_detail: bool, +) -> dict: + """为插件菜单构造一个插件菜单项数据字典""" + status = True + has_superuser_help = False + nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) + if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: + extra_data = PluginExtraData(**nb_plugin.metadata.extra) + if extra_data.superuser_help: + has_superuser_help = True + + if not plugin.status: + if plugin.block_type == BlockType.ALL: + status = False + elif group and plugin.block_type == BlockType.GROUP: + status = False + elif not group and plugin.block_type == BlockType.PRIVATE: + status = False + elif group and f"{plugin.module}," in group.block_plugin: + status = False + elif bot and f"{plugin.module}," in bot.block_plugins: + status = False + + commands = [] + if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: + extra_data = PluginExtraData(**nb_plugin.metadata.extra) + commands = [cmd.command for cmd in extra_data.commands] + + return { + "id": str(plugin.id), + "name": plugin.name, + "status": status, + "has_superuser_help": has_superuser_help, + "commands": commands, + } + + async def create_help_img( session: Uninfo, group_id: str | None, is_detail: bool -) -> Path: - """生成帮助图片 +) -> bytes: + """使用渲染服务生成帮助图片""" + classified_data = await classify_plugin( + session, group_id, is_detail, _create_plugin_menu_item + ) - 参数: - session: Uninfo - group_id: 群号 - """ - help_type = base_config.get("type", "").strip().lower() + sorted_categories = dict( + sorted(classified_data.items(), key=lambda x: len(x[1]), reverse=True) + ) + categories_for_model = [] + plugin_count = 0 + active_count = 0 - match help_type: - case "html": - result = BuildImage.open( - await build_html_image(session, group_id, is_detail) - ) - case "zhenxun": - result = BuildImage.open( - await build_zhenxun_image(session, group_id, is_detail) - ) - case _: - result = await build_normal_image(group_id, is_detail) - if group_id: - save_path = GROUP_HELP_PATH / f"{group_id}_{is_detail}.png" - elif is_detail: - save_path = SIMPLE_DETAIL_HELP_IMAGE - else: - save_path = SIMPLE_HELP_IMAGE - await result.save(save_path) - return save_path + if sorted_categories: + menu_key = next(iter(sorted_categories.keys())) + max_data = sorted_categories.pop(menu_key) + main_category_name = "主要功能" if menu_key in ["normal", "功能"] else menu_key + categories_for_model.append({"name": main_category_name, "items": max_data}) + plugin_count += len(max_data) + active_count += sum(1 for item in max_data if item["status"]) + + for menu, value in sorted_categories.items(): + category_name = "主要功能" if menu in ["normal", "功能"] else menu + categories_for_model.append({"name": category_name, "items": value}) + plugin_count += len(value) + active_count += sum(1 for item in value if item["status"]) + + platform = PlatformUtils.get_platform(session) + bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id + bot_avatar_url = PlatformUtils.get_user_avatar_url(bot_id, platform) or "" + + builder = PluginMenuBuilder( + bot_name=BotConfig.self_nickname, + bot_avatar_url=bot_avatar_url, + is_detail=is_detail, + ) + + for category in categories_for_model: + builder.add_category( + PluginMenuCategory(name=category["name"], items=category["items"]) + ) + + return await builder.build() async def get_user_allow_help(user_id: str) -> list[PluginType]: @@ -92,36 +144,6 @@ async def get_user_allow_help(user_id: str) -> list[PluginType]: return type_list -async def get_normal_help( - metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool -) -> str | bytes: - """构建默认帮助详情 - - 参数: - metadata: PluginMetadata - extra: PluginExtraData - is_superuser: 是否超级用户帮助 - - 返回: - str | bytes: 返回信息 - """ - items = None - if is_superuser: - if usage := extra.superuser_help: - items = { - "简介": metadata.description, - "用法": usage, - } - else: - items = { - "简介": metadata.description, - "用法": metadata.usage, - } - if items: - return (await ImageTemplate.hl_page(metadata.name, items)).pic2bytes() - return "该功能没有帮助信息" - - def min_leading_spaces(str_list: list[str]) -> int: min_spaces = 9999 @@ -142,45 +164,6 @@ def split_text(text: str): return [s.replace(" ", " ") for s in split_text] -async def get_zhenxun_help( - module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool -) -> str | bytes: - """构建ZhenXun帮助详情 - - 参数: - module: 模块名 - metadata: PluginMetadata - extra: PluginExtraData - is_superuser: 是否超级用户帮助 - - 返回: - str | bytes: 返回信息 - """ - call_count = await Statistics.filter(plugin_name=module).count() - usage = metadata.usage - if is_superuser: - if not extra.superuser_help: - return "该功能没有超级用户帮助信息" - usage = extra.superuser_help - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "help_detail").absolute()), - template_name="main.html", - templates={ - "title": metadata.name, - "author": extra.author, - "version": extra.version, - "call_count": call_count, - "descriptions": split_text(metadata.description), - "usages": split_text(usage), - }, - pages={ - "viewport": {"width": 824, "height": 590}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - - async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes: """获取功能的帮助信息 @@ -196,16 +179,42 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | plugin = await PluginInfo.get_or_none( name__iexact=name, load_status=True, plugin_type__in=type_list ) + if plugin: _plugin = nonebot.get_plugin_by_module_name(plugin.module_path) if _plugin and _plugin.metadata: extra_data = PluginExtraData(**_plugin.metadata.extra) - if Config.get_config("help", "detail_type") == "zhenxun": - return await get_zhenxun_help( - plugin.module, _plugin.metadata, extra_data, is_superuser - ) - else: - return await get_normal_help(_plugin.metadata, extra_data, is_superuser) + + call_count = await Statistics.filter(plugin_name=plugin.module).count() + usage = _plugin.metadata.usage + if is_superuser: + if not extra_data.superuser_help: + return "该功能没有超级用户帮助信息" + usage = extra_data.superuser_help + + builder = InfoCardBuilder(title=_plugin.metadata.name) + + builder.add_metadata_items( + [ + ("作者", extra_data.author or "未知"), + ("版本", extra_data.version or "未知"), + ("调用次数", call_count), + ] + ) + + processed_description = format_usage_for_markdown( + _plugin.metadata.description.strip() + ) + processed_usage = format_usage_for_markdown(usage.strip()) + + builder.add_section("简介", [processed_description]) + builder.add_section("使用方法", [processed_usage]) + + style_name = Config.get_config("help", "HELP_STYLE", "default") + render_dict = model_dump(builder._data) + render_dict["style_name"] = style_name + + return await renderer_service.render("pages/builtin/help", data=render_dict) return "糟糕! 该功能没有帮助喔..." return "没有查找到这个功能噢..." @@ -282,10 +291,12 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes: reply_text = response.text if response else "抱歉,我暂时无法回答这个问题。" threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50) + if len(reply_text) > threshold: - markdown = Markdown() - markdown.text(reply_text) - return await markdown.build() + builder = NotebookBuilder() + builder.text(reply_text) + return await builder.build() + return reply_text except LLMException as e: diff --git a/zhenxun/builtin_plugins/help/_utils.py b/zhenxun/builtin_plugins/help/_utils.py index d17edcda..57d3b3d1 100644 --- a/zhenxun/builtin_plugins/help/_utils.py +++ b/zhenxun/builtin_plugins/help/_utils.py @@ -53,5 +53,5 @@ async def classify_plugin( classify[menu] = [] classify[menu].append(handle(bot, plugin, group, is_detail)) for value in classify.values(): - value.sort(key=lambda x: x.id) + value.sort(key=lambda x: int(x["id"])) return classify diff --git a/zhenxun/builtin_plugins/help/detail_help.py b/zhenxun/builtin_plugins/help/detail_help.py deleted file mode 100644 index e69de29b..00000000 diff --git a/zhenxun/builtin_plugins/help/html_help.py b/zhenxun/builtin_plugins/help/html_help.py deleted file mode 100644 index dec0a835..00000000 --- a/zhenxun/builtin_plugins/help/html_help.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import random - -from nonebot_plugin_htmlrender import template_to_pic -from nonebot_plugin_uninfo import Uninfo -from pydantic import BaseModel - -from zhenxun.configs.path_config import TEMPLATE_PATH -from zhenxun.models.bot_console import BotConsole -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.utils.enum import BlockType - -from ._utils import classify_plugin - -LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo" - - -class Item(BaseModel): - plugin_name: str - """插件名称""" - sta: int - """插件状态""" - id: int - """插件id""" - - -class PluginList(BaseModel): - plugin_type: str - """菜单名称""" - icon: str - """图标""" - logo: str - """logo""" - items: list[Item] - """插件列表""" - - -ICON2STR = { - "normal": "fa fa-cog", - "原神相关": "fa fa-circle-o", - "常规插件": "fa fa-cubes", - "联系管理员": "fa fa-envelope-o", - "抽卡相关": "fa fa-credit-card-alt", - "来点好康的": "fa fa-picture-o", - "数据统计": "fa fa-bar-chart", - "一些工具": "fa fa-shopping-cart", - "商店": "fa fa-shopping-cart", - "其它": "fa fa-tags", - "群内小游戏": "fa fa-gamepad", -} - - -def __handle_item( - bot: BotConsole, plugin: PluginInfo, group: GroupConsole | None, is_detail: bool -) -> Item: - """构造Item - - 参数: - bot: BotConsole - plugin: PluginInfo - group: 群组 - is_detail: 是否详细 - - 返回: - Item: Item - """ - sta = 0 - if not plugin.status: - if group and plugin.block_type in [ - BlockType.ALL, - BlockType.GROUP, - ]: - sta = 2 - if not group and plugin.block_type in [ - BlockType.ALL, - BlockType.PRIVATE, - ]: - sta = 2 - if group: - if f"{plugin.module}," in group.superuser_block_plugin: - sta = 2 - if f"{plugin.module}," in group.block_plugin: - sta = 1 - if bot and f"{plugin.module}," in bot.block_plugins: - sta = 2 - return Item(plugin_name=plugin.name, sta=sta, id=plugin.id) - - -def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: - """构建前端插件数据 - - 参数: - classify: 插件数据 - - 返回: - list[dict[str, str]]: 前端插件数据 - """ - lengths = [len(classify[c]) for c in classify] - index = lengths.index(max(lengths)) - menu_key = list(classify.keys())[index] - max_data = classify[menu_key] - del classify[menu_key] - plugin_list = [] - for menu_type in classify: - icon = "fa fa-pencil-square-o" - if menu_type in ICON2STR.keys(): - icon = ICON2STR[menu_type] - logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH)) - data = { - "name": menu_type if menu_type != "normal" else "功能", - "items": classify[menu_type], - "icon": icon, - "logo": str(logo.absolute()), - } - plugin_list.append(data) - plugin_list.insert( - 0, - { - "name": menu_key if menu_key != "normal" else "功能", - "items": max_data, - "icon": "fa fa-pencil-square-o", - "logo": str((LOGO_PATH / random.choice(os.listdir(LOGO_PATH))).absolute()), - }, - ) - return plugin_list - - -async def build_html_image( - session: Uninfo, group_id: str | None, is_detail: bool -) -> bytes: - """构造HTML帮助图片 - - 参数: - session: Uninfo - group_id: 群号 - is_detail: 是否详细帮助 - """ - classify = await classify_plugin(session, group_id, is_detail, __handle_item) - plugin_list = build_plugin_data(classify) - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "menu").absolute()), - template_name="zhenxun_menu.html", - templates={"plugin_list": plugin_list}, - pages={ - "viewport": {"width": 1903, "height": 10}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) diff --git a/zhenxun/builtin_plugins/help/normal_help.py b/zhenxun/builtin_plugins/help/normal_help.py deleted file mode 100644 index f381f900..00000000 --- a/zhenxun/builtin_plugins/help/normal_help.py +++ /dev/null @@ -1,100 +0,0 @@ -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.models.group_console import GroupConsole -from zhenxun.utils._build_image import BuildImage -from zhenxun.utils.enum import BlockType -from zhenxun.utils.image_utils import build_sort_image, group_image - -from ._utils import sort_type - -BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help" - - -async def build_normal_image(group_id: str | None, is_detail: bool) -> BuildImage: - """构造PIL帮助图片 - - 参数: - group_id: 群号 - is_detail: 详细帮助 - """ - image_list = [] - font_size = 24 - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - sort_data = await sort_type() - for idx, menu_type in enumerate(sort_data): - plugin_list = sort_data[menu_type] - """拿到最大宽度和结算高度""" - wh_list = [ - BuildImage.get_text_size(f"{x.id}.{x.name}", font) for x in plugin_list - ] - wh_list.append(BuildImage.get_text_size(menu_type, font)) - sum_height = (font_size + 6) * len(plugin_list) + 10 - max_width = max(x[0] for x in wh_list) + 30 - bk = BuildImage( - max_width + 40, - sum_height + 50, - font_size=30, - color="#a7d1fc", - font="CJGaoDeGuo.otf", - ) - title_size = bk.getsize(menu_type) - max_width = max_width if max_width > title_size[0] else title_size[0] - row = BuildImage( - max_width + 40, - sum_height, - font_size=font_size, - color="black" if idx % 2 else "white", - ) - curr_h = 10 - group = await GroupConsole.get_group(group_id=group_id) if group_id else None - for _, plugin in enumerate(plugin_list): - text_color = (255, 255, 255) if idx % 2 else (0, 0, 0) - if group and f"{plugin.module}," in group.block_plugin: - text_color = (252, 75, 13) - pos = None - # 禁用状态划线 - if plugin.block_type in [BlockType.ALL, BlockType.GROUP] or ( - group and f"super:{plugin.module}," in group.block_plugin - ): - w = curr_h + int(row.getsize(plugin.name)[1] / 2) + 2 - line_width = row.getsize(plugin.name)[0] + 35 - pos = (7, w, line_width, w) - await row.text((10, curr_h), f"{plugin.id}.{plugin.name}", text_color) - if pos: - await row.line(pos, (236, 66, 7), 3) - curr_h += font_size + 5 - await bk.text((0, 14), menu_type, center_type="width") - await bk.paste(row, (0, 50)) - await bk.transparent(2) - image_list.append(bk) - image_group, h = group_image(image_list) - - async def _a(image: BuildImage): - await image.filter("GaussianBlur", 5) - - result = await build_sort_image( - image_group, - h, - background_path=BACKGROUND_PATH, - background_handle=_a, - ) - width, height = 10, 10 - for s in [ - "目前支持的功能列表:", - "可以通过 '帮助 [功能名称或功能Id]' 来获取对应功能的使用方法", - ]: - text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24) - await result.paste(text, (width, height)) - height += 50 - if s == "目前支持的功能列表:": - width += 50 - text = await BuildImage.build_text_image( - "注: 红字代表功能被群管理员禁用,红线代表功能正在维护", - "HYWenHei-85W.ttf", - 24, - (231, 74, 57), - ) - await result.paste( - text, - (300, 10), - ) - return result diff --git a/zhenxun/builtin_plugins/help/zhenxun_help.py b/zhenxun/builtin_plugins/help/zhenxun_help.py deleted file mode 100644 index ea04bdc0..00000000 --- a/zhenxun/builtin_plugins/help/zhenxun_help.py +++ /dev/null @@ -1,143 +0,0 @@ -import nonebot -from nonebot_plugin_htmlrender import template_to_pic -from nonebot_plugin_uninfo import Uninfo -from pydantic import BaseModel - -from zhenxun.configs.config import BotConfig -from zhenxun.configs.path_config import TEMPLATE_PATH -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.bot_console import BotConsole -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.utils.enum import BlockType -from zhenxun.utils.platform import PlatformUtils - -from ._utils import classify_plugin - - -class Item(BaseModel): - plugin_name: str - """插件名称""" - commands: list[str] - """插件命令""" - id: str - """插件id""" - status: bool - """插件状态""" - has_superuser_help: bool - """插件是否拥有超级用户帮助""" - - -def __handle_item( - bot: BotConsole | None, - plugin: PluginInfo, - group: GroupConsole | None, - is_detail: bool, -): - """构造Item - - 参数: - bot: BotConsole - plugin: PluginInfo - group: 群组 - is_detail: 是否为详细 - - 返回: - Item: Item - """ - status = True - has_superuser_help = False - nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) - if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: - extra_data = PluginExtraData(**nb_plugin.metadata.extra) - if extra_data.superuser_help: - has_superuser_help = True - if not plugin.status: - if plugin.block_type == BlockType.ALL: - status = False - elif group and plugin.block_type == BlockType.GROUP: - status = False - elif not group and plugin.block_type == BlockType.PRIVATE: - status = False - elif group and f"{plugin.module}," in group.block_plugin: - status = False - elif bot and f"{plugin.module}," in bot.block_plugins: - status = False - commands = [] - nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) - if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: - extra_data = PluginExtraData(**nb_plugin.metadata.extra) - commands = [cmd.command for cmd in extra_data.commands] - return Item( - plugin_name=plugin.name, - commands=commands, - id=str(plugin.id), - status=status, - has_superuser_help=has_superuser_help, - ) - - -def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: - """构建前端插件数据 - - 参数: - classify: 插件数据 - - 返回: - list[dict[str, str]]: 前端插件数据 - """ - classify = dict(sorted(classify.items(), key=lambda x: len(x[1]), reverse=True)) - menu_key = next(iter(classify.keys())) - max_data = classify[menu_key] - del classify[menu_key] - plugin_list = [ - { - "name": "主要功能" if menu in ["normal", "功能"] else menu, - "items": value, - } - for menu, value in classify.items() - ] - plugin_list.insert(0, {"name": menu_key, "items": max_data}) - for plugin in plugin_list: - plugin["items"].sort(key=lambda x: x.id) - return plugin_list - - -async def build_zhenxun_image( - session: Uninfo, group_id: str | None, is_detail: bool -) -> bytes: - """构造真寻帮助图片 - - 参数: - bot_id: bot_id - group_id: 群号 - is_detail: 是否详细帮助 - """ - classify = await classify_plugin(session, group_id, is_detail, __handle_item) - plugin_list = build_plugin_data(classify) - platform = PlatformUtils.get_platform(session) - bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id - bot_ava = PlatformUtils.get_user_avatar_url(bot_id, platform) - width = int(637 * 1.5) if is_detail else 637 - title_font = int(53 * 1.5) if is_detail else 53 - tip_font = int(19 * 1.5) if is_detail else 19 - plugin_count = sum(len(plugin["items"]) for plugin in plugin_list) - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "ss_menu").absolute()), - template_name="main.html", - templates={ - "data": { - "plugin_list": plugin_list, - "ava": bot_ava, - "width": width, - "font_size": (title_font, tip_font), - "is_detail": is_detail, - "plugin_count": plugin_count, - } - }, - pages={ - "viewport": {"width": width, "height": 10}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) diff --git a/zhenxun/builtin_plugins/info/my_info.py b/zhenxun/builtin_plugins/info/my_info.py index 0621c3d5..488aee71 100644 --- a/zhenxun/builtin_plugins/info/my_info.py +++ b/zhenxun/builtin_plugins/info/my_info.py @@ -1,17 +1,16 @@ from datetime import datetime, timedelta import random -from nonebot_plugin_htmlrender import template_to_pic from nonebot_plugin_uninfo import Uninfo from tortoise.expressions import RawSQL from tortoise.functions import Count -from zhenxun.configs.path_config import TEMPLATE_PATH from zhenxun.models.chat_history import ChatHistory from zhenxun.models.level_user import LevelUser from zhenxun.models.sign_user import SignUser from zhenxun.models.statistics import Statistics from zhenxun.models.user_console import UserConsole +from zhenxun.services import renderer_service from zhenxun.utils.platform import PlatformUtils RACE = [ @@ -90,7 +89,7 @@ def get_level(impression: float) -> int: async def get_chat_history( user_id: str, group_id: str | None -) -> tuple[list[str], list[str]]: +) -> tuple[list[str], list[int]]: """获取用户聊天记录 参数: @@ -98,11 +97,11 @@ async def get_chat_history( group_id: 群id 返回: - tuple[list[str], list[str]]: 日期列表, 次数列表 + tuple[list[str], list[int]]: 日期列表, 次数列表 """ now = datetime.now() - filter_date = now - timedelta(days=7, hours=now.hour, minutes=now.minute) + filter_date = now - timedelta(days=7) date_list = ( await ChatHistory.filter( user_id=user_id, group_id=group_id, create_time__gte=filter_date @@ -111,19 +110,15 @@ async def get_chat_history( .group_by("date") .values("date", "count") ) - chart_date = [] - count_list = [] - date2cnt = {str(date["date"]): date["count"] for date in date_list} - date = now.date() + chart_date: list[str] = [] + count_list: list[int] = [] + date2cnt = {str(item["date"]): item["count"] for item in date_list} + current_date = now.date() for _ in range(7): - if str(date) in date2cnt: - count_list.append(date2cnt[str(date)]) - else: - count_list.append(0) - chart_date.append(str(date)) - date -= timedelta(days=1) - for c in chart_date: - chart_date[chart_date.index(c)] = c[5:] + date_str = str(current_date) + count_list.append(date2cnt.get(date_str, 0)) + chart_date.append(date_str[5:]) + current_date -= timedelta(days=1) chart_date.reverse() count_list.reverse() return chart_date, count_list @@ -136,7 +131,6 @@ async def get_user_info( 参数: session: Uninfo - bot: Bot user_id: 用户id group_id: 群id nickname: 用户昵称 @@ -145,50 +139,63 @@ async def get_user_info( bytes: 图片数据 """ platform = PlatformUtils.get_platform(session) or "qq" - ava_url = PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) + avatar_url = ( + PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) or "" + ) + user = await UserConsole.get_user(user_id, platform) - level = await LevelUser.get_user_level(user_id, group_id) + permission_level = await LevelUser.get_user_level(user_id, group_id) + sign_level = 0 if sign_user := await SignUser.get_or_none(user_id=user_id): sign_level = get_level(float(sign_user.impression)) + chat_count = await ChatHistory.filter(user_id=user_id, group_id=group_id).count() stat_count = await Statistics.filter(user_id=user_id, group_id=group_id).count() - select_index = ["" for _ in range(9)] - select_index[sign_level] = "select" + + selected_indices = [""] * 9 + selected_indices[sign_level] = "select" + uid = f"{user.uid}".rjust(8, "0") - uid = f"{uid[:4]} {uid[4:]}" + uid_formatted = f"{uid[:4]} {uid[4:]}" + now = datetime.now() - weather = "moon" if now.hour < 6 or now.hour > 19 else "sun" - chart_date, count_list = await get_chat_history(user_id, group_id) - data = { - "date": now.date(), - "weather": weather, - "ava_url": ava_url, - "nickname": nickname, - "title": "勇 者", - "race": random.choice(RACE), - "sex": random.choice(SEX), - "occ": random.choice(OCC), - "uid": uid, - "description": "这是一个传奇的故事," - "人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟译。", - "sign_level": sign_level, - "level": level, - "gold": user.gold, - "prop": len(user.props), - "call": stat_count, - "say": chat_count, - "select_index": select_index, - "chart_date": chart_date, - "count_list": count_list, - } - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "my_info").absolute()), - template_name="main.html", - templates={"data": data}, - pages={ - "viewport": {"width": 1754, "height": 1240}, - "base_url": f"file://{TEMPLATE_PATH}", + weather_icon_name = "moon" if now.hour < 6 or now.hour > 19 else "sun" + + chart_labels, chart_data = await get_chat_history(user_id, group_id) + + profile_data = { + "page": { + "date": str(now.date()), + "weather_icon_name": weather_icon_name, }, - wait=2, - ) + "info": { + "avatar_url": avatar_url, + "nickname": nickname, + "title": "勇 者", + "race": random.choice(RACE), + "sex": random.choice(SEX), + "occupation": random.choice(OCC), + "uid": uid_formatted, + "description": ( + "这是一个传奇的故事,人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟大" + ), + }, + "stats": { + "gold": user.gold, + "prop_count": len(user.props), + "call_count": stat_count, + "chat_count": chat_count, + }, + "favorability": { + "level": sign_level, + "selected_indices": selected_indices, + }, + "permission_level": permission_level, + "chart": { + "labels": chart_labels, + "data": chart_data, + }, + } + + return await renderer_service.render("pages/builtin/my_info", data=profile_data) diff --git a/zhenxun/builtin_plugins/llm_manager/presenters.py b/zhenxun/builtin_plugins/llm_manager/presenters.py index d745aaf7..c376f346 100644 --- a/zhenxun/builtin_plugins/llm_manager/presenters.py +++ b/zhenxun/builtin_plugins/llm_manager/presenters.py @@ -2,8 +2,8 @@ from typing import Any from zhenxun.services.llm.core import KeyStatus from zhenxun.services.llm.types import ModelModality -from zhenxun.utils._build_image import BuildImage -from zhenxun.utils._image_template import ImageTemplate, Markdown, RowStyle +from zhenxun.ui import MarkdownBuilder, TableBuilder +from zhenxun.ui.models import StatusBadgeCell, TextCell def _format_seconds(seconds: int) -> str: @@ -27,35 +27,40 @@ class Presenters: @staticmethod async def format_model_list_as_image( models: list[dict[str, Any]], show_all: bool - ) -> BuildImage: + ) -> bytes: """将模型列表格式化为表格图片""" - title = "📋 LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)") + title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)") if not models: - return await BuildImage.build_text_image( - f"{title}\n\n当前没有配置任何LLM模型。" - ) + builder = TableBuilder( + title=title, tip="当前没有配置任何LLM模型。" + ).set_headers(["提供商", "模型名称", "API类型", "状态"]) + return await builder.build() column_name = ["提供商", "模型名称", "API类型", "状态"] data_list = [] for model in models: - status_text = "✅ 可用" if model.get("is_available", True) else "❌ 不可用" + is_available = model.get("is_available", True) + status_cell = StatusBadgeCell( + text="可用" if is_available else "不可用", + status_type="ok" if is_available else "error", + ) embed_tag = " (Embed)" if model.get("is_embedding_model", False) else "" data_list.append( [ - model.get("provider_name", "N/A"), - f"{model.get('model_name', 'N/A')}{embed_tag}", - model.get("api_type", "N/A"), - status_text, + TextCell(content=model.get("provider_name", "N/A")), + TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"), + TextCell(content=model.get("api_type", "N/A")), + status_cell, ] ) - return await ImageTemplate.table_page( - head_text=title, - tip_text="使用 `llm info ` 查看详情", - column_name=column_name, - data_list=data_list, + builder = TableBuilder( + title=title, tip="使用 `llm info ` 查看详情" ) + builder.set_headers(column_name) + builder.add_rows(data_list) + return await builder.build(use_cache=True) @staticmethod async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes: @@ -76,77 +81,33 @@ class Presenters: if caps.is_embedding_model: cap_list.append("文本嵌入") - md = Markdown() - md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", level=1) - md.text("---") - md.head("提供商信息", level=2) - md.list( - [ - f"**名称**: {provider.name}", - f"**API 类型**: {provider.api_type}", - f"**API Base**: {provider.api_base or '默认'}", - ] - ) - md.head("模型详情", level=2) + 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 '默认'}") + + builder.head("模型详情", 2) temp_value = model.temperature or provider.temperature or "未设置" token_value = model.max_tokens or provider.max_tokens or "未设置" - md.list( - [ - f"**名称**: {model.model_name}", - f"**默认温度**: {temp_value}", - f"**最大Token**: {token_value}", - f"**核心能力**: {', '.join(cap_list) 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 '纯文本'}") - return await md.build() + return await builder.with_style("light").build() @staticmethod async def format_key_status_as_image( provider_name: str, sorted_stats: list[dict[str, Any]] - ) -> BuildImage: + ) -> bytes: """将已排序的、详细的API Key状态格式化为表格图片""" title = f"🔑 '{provider_name}' API Key 状态" - if not sorted_stats: - return await BuildImage.build_text_image( - f"{title}\n\n该提供商没有配置API Keys。" - ) - - def _status_row_style(column: str, text: str) -> RowStyle: - style = RowStyle() - if column == "状态": - if "✅ 健康" in text: - style.font_color = "#67C23A" - elif "⚠️ 告警" in text: - style.font_color = "#E6A23C" - elif "❌ 错误" in text or "🚫" in text: - style.font_color = "#F56C6C" - elif "❄️ 冷却中" in text: - style.font_color = "#409EFF" - elif column == "成功率": - try: - if text != "N/A": - rate = float(text.replace("%", "")) - if rate < 80: - style.font_color = "#F56C6C" - elif rate < 95: - style.font_color = "#E6A23C" - except (ValueError, TypeError): - pass - return style - - column_name = [ - "Key (部分)", - "状态", - "总调用", - "成功率", - "平均延迟(s)", - "上次错误", - "建议操作", - ] data_list = [] for key_info in sorted_stats: @@ -155,15 +116,19 @@ class Presenters: if status_enum == KeyStatus.COOLDOWN: cooldown_seconds = int(key_info["cooldown_seconds_left"]) formatted_time = _format_seconds(cooldown_seconds) - status_text = f"❄️ 冷却中({formatted_time})" + status_cell = StatusBadgeCell( + text=f"冷却中({formatted_time})", status_type="info" + ) else: - status_text = { - KeyStatus.DISABLED: "🚫 永久禁用", - KeyStatus.ERROR: "❌ 错误", - KeyStatus.WARNING: "⚠️ 告警", - KeyStatus.HEALTHY: "✅ 健康", - KeyStatus.UNUSED: "⚪️ 未使用", - }.get(status_enum, "❔ 未知") + status_map = { + KeyStatus.DISABLED: ("永久禁用", "error"), + KeyStatus.ERROR: ("错误", "error"), + KeyStatus.WARNING: ("告警", "warning"), + KeyStatus.HEALTHY: ("健康", "ok"), + KeyStatus.UNUSED: ("未使用", "info"), + } + text, status_type = status_map.get(status_enum, ("未知", "info")) + status_cell = StatusBadgeCell(text=text, status_type=status_type) # type: ignore total_calls = key_info["total_calls"] total_calls_text = ( @@ -174,6 +139,13 @@ class Presenters: success_rate = key_info["success_rate"] success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A" + rate_color = None + if total_calls > 0: + if success_rate < 80: + rate_color = "#F56C6C" + elif success_rate < 95: + rate_color = "#E6A23C" + success_rate_cell = TextCell(content=success_rate_text, color=rate_color) avg_latency = key_info["avg_latency"] avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A" @@ -184,21 +156,29 @@ class Presenters: data_list.append( [ - key_info["key_id"], - status_text, - total_calls_text, - success_rate_text, - avg_latency_text, - last_error, - key_info["suggested_action"], + TextCell(content=key_info["key_id"]), + status_cell, + TextCell(content=total_calls_text), + success_rate_cell, + TextCell(content=avg_latency_text), + TextCell(content=last_error), + TextCell(content=key_info["suggested_action"]), ] ) - return await ImageTemplate.table_page( - head_text=title, - tip_text="使用 `llm reset-key ` 重置Key状态", - column_name=column_name, - data_list=data_list, - text_style=_status_row_style, - column_space=15, + builder = TableBuilder( + title=title, tip="使用 `llm reset-key ` 重置Key状态" ) + builder.set_headers( + [ + "Key (部分)", + "状态", + "总调用", + "成功率", + "平均延迟(s)", + "上次错误", + "建议操作", + ] + ) + builder.add_rows(data_list) + return await builder.build(use_cache=False) diff --git a/zhenxun/builtin_plugins/mahiro_bank/__init__.py b/zhenxun/builtin_plugins/mahiro_bank/__init__.py index 8e82cf08..fd589afc 100644 --- a/zhenxun/builtin_plugins/mahiro_bank/__init__.py +++ b/zhenxun/builtin_plugins/mahiro_bank/__init__.py @@ -8,6 +8,7 @@ from nonebot_plugin_waiter import prompt_until from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger +from zhenxun.services.renderer import renderer_service from zhenxun.utils.depends import UserName from zhenxun.utils.message import MessageUtils from zhenxun.utils.utils import is_number @@ -188,15 +189,33 @@ async def _(session: Uninfo, arparma: Arparma, amount: Match[int]): @_matcher.assign("user-info") async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()): - result = await BankManager.get_user_info(session, uname) - await MessageUtils.build_message(result).send() + user_payload = await BankManager.get_user_info_data(session, uname) + + render_data = {"page_type": "user", "payload": user_payload} + + image_bytes = await renderer_service.render( + "pages/builtin/mahiro_bank", + data=render_data, + viewport={"width": 386, "height": 10}, + ) + + await MessageUtils.build_message(image_bytes).send() logger.info("查看银行个人信息", arparma.header_result, session=session) @_matcher.assign("bank-info") async def _(session: Uninfo, arparma: Arparma): - result = await BankManager.get_bank_info() - await MessageUtils.build_message(result).send() + overview_payload = await BankManager.get_bank_info_data() + + render_data = {"page_type": "overview", "payload": overview_payload} + + image_bytes = await renderer_service.render( + "pages/builtin/mahiro_bank", + data=render_data, + viewport={"width": 450, "height": 10}, + ) + + await MessageUtils.build_message(image_bytes).send() logger.info("查看银行信息", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/mahiro_bank/data_source.py b/zhenxun/builtin_plugins/mahiro_bank/data_source.py index b717e9a4..8e210447 100644 --- a/zhenxun/builtin_plugins/mahiro_bank/data_source.py +++ b/zhenxun/builtin_plugins/mahiro_bank/data_source.py @@ -2,13 +2,11 @@ import asyncio from datetime import datetime, timedelta import random -from nonebot_plugin_htmlrender import template_to_pic from nonebot_plugin_uninfo import Uninfo from tortoise.expressions import RawSQL from tortoise.functions import Count, Sum from zhenxun.configs.config import Config -from zhenxun.configs.path_config import TEMPLATE_PATH from zhenxun.models.mahiro_bank import MahiroBank from zhenxun.models.mahiro_bank_log import MahiroBankLog from zhenxun.models.sign_user import SignUser @@ -158,15 +156,15 @@ class BankManager: ) @classmethod - async def get_user_info(cls, session: Uninfo, uname: str) -> bytes: - """获取用户数据 + async def get_user_info_data(cls, session: Uninfo, uname: str) -> dict: + """获取用户数据(返回字典) 参数: session: Uninfo uname: 用户id 返回: - bytes: 图片数据 + dict: 用户银行数据字典 """ user_id = session.user.id user = await cls.get_user(user_id=user_id) @@ -199,9 +197,9 @@ class BankManager: deposit_list = [ { "id": deposit.id, - "date": now.date(), + "date": str(now.date()), "start_time": str(deposit.create_time).split(".")[0], - "end_time": end_time.replace(microsecond=0), + "end_time": str(end_time.replace(microsecond=0)), "amount": deposit.amount, "rate": f"{deposit.rate * 100:.2f}", "projected_revenue": int( @@ -212,12 +210,13 @@ class BankManager: for deposit in user_today_deposit ] platform = PlatformUtils.get_platform(session) - data = { + avatar_url = PlatformUtils.get_user_avatar_url( + user_id, platform, session.self_id + ) + return { "name": uname, "rank": rank + 1, - "avatar_url": PlatformUtils.get_user_avatar_url( - user_id, platform, session.self_id - ), + "avatar_url": avatar_url or "", "amount": user.amount, "deposit_count": deposit_count, "today_deposit_count": len(user_today_deposit), @@ -225,21 +224,16 @@ class BankManager: "projected_revenue": projected_revenue, "today_deposit_amount": today_deposit_amount, "deposit_list": deposit_list, - "create_time": now.replace(microsecond=0), + "create_time": str(now.replace(microsecond=0)), } - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()), - template_name="user.html", - templates={"data": data}, - pages={ - "viewport": {"width": 386, "height": 700}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) @classmethod - async def get_bank_info(cls) -> bytes: + async def get_bank_info_data(cls) -> dict: + """获取银行总览数据(返回字典) + + 返回: + dict: 银行总览数据字典 + """ now = datetime.now() now_start = now - timedelta( hours=now.hour, minutes=now.minute, seconds=now.second @@ -293,27 +287,17 @@ class BankManager: if lasted_log: date = now.date() - lasted_log.create_time.date() date = (date.days or 1) + 1 - data = { - "amount_sum": bank_data[0]["amount_sum"], - "user_count": bank_data[0]["user_count"], + return { + "amount_sum": bank_data[0]["amount_sum"] or 0, + "user_count": bank_data[0]["user_count"] or 0, "today_count": today_count, - "day_amount": int(bank_data[0]["amount_sum"] / date), + "day_amount": int((bank_data[0]["amount_sum"] or 0) / date), "interest_amount": interest_amount[0]["amount_sum"] or 0, "active_user_count": active_user_count[0]["count"] or 0, "e_data": e_date, "e_amount": e_amount, - "create_time": now.replace(microsecond=0), + "create_time": str(now.replace(microsecond=0)), } - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()), - template_name="bank.html", - templates={"data": data}, - pages={ - "viewport": {"width": 450, "height": 750}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) @classmethod async def deposit( @@ -406,7 +390,6 @@ class BankManager: bank_data[log.user_id].append(log) log_create_list = [] log_update_list = [] - # 计算每日默认金币 for bank_user in bank_user_list: if user := user_data.get(bank_user.user_id): amount = bank_user.amount @@ -414,7 +397,6 @@ class BankManager: amount -= sum(log.amount for log in logs) if not amount: continue - # 计算每日默认金币 gold = int(amount * bank_user.rate) user.gold += gold log_create_list.append( @@ -426,7 +408,6 @@ class BankManager: is_completed=True, ) ) - # 计算每日存款金币 for user_id, logs in bank_data.items(): if user := user_data.get(user_id): for log in logs: diff --git a/zhenxun/builtin_plugins/shop/_data_source.py b/zhenxun/builtin_plugins/shop/_data_source.py index 29a7b458..92302806 100644 --- a/zhenxun/builtin_plugins/shop/_data_source.py +++ b/zhenxun/builtin_plugins/shop/_data_source.py @@ -1,4 +1,5 @@ import asyncio +from collections import defaultdict from collections.abc import Callable from datetime import datetime, timedelta import inspect @@ -7,26 +8,26 @@ from types import MappingProxyType from typing import Any, Literal from nonebot.adapters import Bot, Event -from nonebot.compat import model_dump from nonebot_plugin_alconna import At, UniMessage, UniMsg from nonebot_plugin_uninfo import Uninfo from pydantic import BaseModel, Field, create_model from tortoise.expressions import Q +from zhenxun.configs.config import BotConfig from zhenxun.models.friend_user import FriendUser from zhenxun.models.goods_info import GoodsInfo from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.user_console import UserConsole from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_props_log import UserPropsLog +from zhenxun.services import renderer_service from zhenxun.services.log import logger from zhenxun.utils.enum import GoldHandle, PropHandle from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.platform import PlatformUtils +from zhenxun.utils.pydantic_compat import model_dump -from .config import ICON_PATH, PLATFORM_PATH, base_config -from .html_image import html_image -from .normal_image import normal_image +from .config import ICON_PATH, PLATFORM_PATH class Goods(BaseModel): @@ -150,9 +151,7 @@ class ShopManage: @classmethod async def get_shop_image(cls) -> bytes: - if base_config.get("style") == "zhenxun": - return await html_image() - return await normal_image() + return await prepare_shop_data() @classmethod def __build_params( @@ -565,3 +564,62 @@ class ShopManage: """ user = await UserConsole.get_user(user_id, platform) return user.gold + + +def get_limit_time(end_time: int) -> str | None: + now = int(time.time()) + if now > end_time or end_time == 0: + return None + time_difference = datetime.fromtimestamp(end_time) - datetime.fromtimestamp(now) + total_seconds = time_difference.total_seconds() + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + return f"{hours}:{minutes:02d}" + + +def get_discount(price: int, discount: float) -> int | None: + return None if discount == 1.0 else int(price * discount) + + +async def prepare_shop_data() -> bytes: + """准备商店数据并调用渲染服务""" + goods_list = ( + await GoodsInfo.filter( + Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0) + ) + .annotate() + .order_by("id") + .all() + ) + + partition_dict: dict[str, list[dict]] = defaultdict(list) + for idx, goods in enumerate(goods_list): + partition_name = goods.partition or "默认分区" + + icon_asset_path = None + if goods.icon and (ICON_PATH / goods.icon).exists(): + icon_asset_path = f"image/shop_icon/{goods.icon}" + + goods_item = { + "id": idx + 1, + "name": goods.goods_name, + "description": goods.goods_description, + "price": goods.goods_price, + "discount_price": get_discount(goods.goods_price, goods.goods_discount), + "limit_time": get_limit_time(goods.goods_limit_time), + "daily_limit": goods.daily_limit or "∞", + "icon_url": icon_asset_path, + } + partition_dict[partition_name].append(goods_item) + + categories = [ + {"partition_title": partition, "goods_list": items} + for partition, items in partition_dict.items() + ] + + shop_data = { + "bot_nickname": BotConfig.self_nickname, + "categories": categories, + } + + return await renderer_service.render("pages/builtin/shop", data=shop_data) diff --git a/zhenxun/builtin_plugins/shop/config.py b/zhenxun/builtin_plugins/shop/config.py index 7fa13a5d..5b335141 100644 --- a/zhenxun/builtin_plugins/shop/config.py +++ b/zhenxun/builtin_plugins/shop/config.py @@ -1,5 +1,5 @@ from zhenxun.configs.config import Config -from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH +from zhenxun.configs.path_config import IMAGE_PATH, THEMES_PATH base_config = Config.get("shop") @@ -17,4 +17,4 @@ PLATFORM_PATH = { LEFT_RIGHT_IMAGE = ["1.png", "2.png", "qq.png"] -LEFT_RIGHT_PATH = TEMPLATE_PATH / "shop" / "res" / "img" +LEFT_RIGHT_PATH = THEMES_PATH / "default" / "assets" / "shop" / "img" diff --git a/zhenxun/builtin_plugins/shop/html_image.py b/zhenxun/builtin_plugins/shop/html_image.py deleted file mode 100644 index 2d7948cb..00000000 --- a/zhenxun/builtin_plugins/shop/html_image.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime -import time - -from nonebot_plugin_htmlrender import template_to_pic -from pydantic import BaseModel -from tortoise.expressions import Q - -from zhenxun.configs.config import BotConfig -from zhenxun.configs.path_config import TEMPLATE_PATH -from zhenxun.models.goods_info import GoodsInfo -from zhenxun.utils._build_image import BuildImage - -from .config import ICON_PATH - - -class GoodsItem(BaseModel): - goods_list: list[dict] - """商品列表""" - partition: str - """分区名称""" - - -def get_limit_time(end_time: int): - now = int(time.time()) - if now > end_time: - return None - current_datetime = datetime.fromtimestamp(now) - end_datetime = datetime.fromtimestamp(end_time) - time_difference = end_datetime - current_datetime - total_seconds = time_difference.total_seconds() - hours = int(total_seconds // 3600) - minutes = int((total_seconds % 3600) // 60) - return f"{hours}:{minutes}" - - -def get_discount(price: int, discount: float): - return None if discount == 1.0 else int(price * discount) - - -async def html_image() -> bytes: - """构建图片""" - goods_list = ( - await GoodsInfo.filter( - Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0) - ) - .annotate() - .order_by("id") - .all() - ) - partition_dict: dict[str, list[dict]] = {} - for idx, goods in enumerate(goods_list): - if not goods.partition: - goods.partition = "默认分区" - if goods.partition not in partition_dict: - partition_dict[goods.partition] = [] - icon = None - if goods.icon: - path = ICON_PATH / goods.icon - if path.exists(): - icon = ( - "data:image/png;base64," - f"{BuildImage.open(ICON_PATH / goods.icon).pic2bs4()[9:]}" - ) - partition_dict[goods.partition].append( - { - "id": idx + 1, - "price": goods.goods_price, - "discount_price": get_discount(goods.goods_price, goods.goods_discount), - "limit_time": get_limit_time(goods.goods_limit_time), - "daily_limit": goods.daily_limit or "∞", - "name": goods.goods_name, - "icon": icon, - "description": goods.goods_description, - } - ) - data_list = [ - GoodsItem(goods_list=value, partition=partition) - for partition, value in partition_dict.items() - ] - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "shop").absolute()), - template_name="main.html", - templates={"name": BotConfig.self_nickname, "data_list": data_list}, - pages={ - "viewport": {"width": 850, "height": 1024}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) diff --git a/zhenxun/builtin_plugins/shop/normal_image.py b/zhenxun/builtin_plugins/shop/normal_image.py deleted file mode 100644 index 7b5004cf..00000000 --- a/zhenxun/builtin_plugins/shop/normal_image.py +++ /dev/null @@ -1,207 +0,0 @@ -import time - -from tortoise.expressions import Q - -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.models.goods_info import GoodsInfo -from zhenxun.utils._build_image import BuildImage -from zhenxun.utils.image_utils import text2image - -from .config import ICON_PATH - - -async def normal_image() -> bytes: - """制作商店图片 - - 返回: - BuildImage: 商店图片 - """ - h = 10 - goods_list = ( - await GoodsInfo.filter( - Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0) - ) - .annotate() - .order_by("id") - .all() - ) - # A = BuildImage(1100, h, color="#f9f6f2") - total_n = 0 - image_list = [] - for idx, goods in enumerate(goods_list): - name_image = BuildImage( - 580, 40, font_size=25, color="#e67b6b", font="CJGaoDeGuo.otf" - ) - await name_image.text( - (15, 0), f"{idx + 1}.{goods.goods_name}", center_type="height" - ) - await name_image.line((380, -5, 280, 45), "#a29ad6", 5) - await name_image.text((390, 0), "售价:", center_type="height") - if goods.goods_discount != 1: - discount_price = int(goods.goods_discount * goods.goods_price) - old_price_image = await BuildImage.build_text_image( - str(goods.goods_price), font_color=(194, 194, 194), size=15 - ) - await old_price_image.line( - ( - 0, - int(old_price_image.height / 2), - old_price_image.width + 1, - int(old_price_image.height / 2), - ), - (0, 0, 0), - ) - await name_image.paste(old_price_image, (440, 0)) - await name_image.text((440, 15), str(discount_price), (255, 255, 255)) - else: - await name_image.text( - (440, 0), - str(goods.goods_price), - (255, 255, 255), - center_type="height", - ) - _tmp = await BuildImage.build_text_image(str(goods.goods_price), size=25) - await name_image.text( - ( - 440 + _tmp.width, - 0, - ), - " 金币", - center_type="height", - ) - des_image = None - font_img = BuildImage(600, 80, font_size=20, color="#a29ad6") - p = font_img.getsize("简介:")[0] + 20 - if goods.goods_description: - des_list = goods.goods_description.split("\n") - desc = "" - for des in des_list: - if font_img.getsize(des)[0] > font_img.width - p - 20: - msg = "" - tmp = "" - for i in range(len(des)): - if font_img.getsize(tmp)[0] < font_img.width - p - 20: - tmp += des[i] - else: - msg += tmp + "\n" - tmp = des[i] - desc += msg - if tmp: - desc += tmp - else: - desc += des + "\n" - if desc[-1] == "\n": - desc = desc[:-1] - des_image = await text2image(desc, color="#a29ad6") - goods_image = BuildImage( - 600, - (50 + des_image.height) if des_image else 50, - font_size=20, - color="#a29ad6", - font="CJGaoDeGuo.otf", - ) - if des_image: - await goods_image.text((15, 50), "简介:") - await goods_image.paste(des_image, (p, 50)) - await name_image.circle_corner(5) - await goods_image.paste(name_image, (0, 5), center_type="width") - await goods_image.circle_corner(20) - bk = BuildImage( - 1180, - (50 + des_image.height) if des_image else 50, - font_size=15, - color="#f9f6f2", - font="CJGaoDeGuo.otf", - ) - if goods.icon and (ICON_PATH / goods.icon).exists(): - icon = BuildImage(70, 70, background=ICON_PATH / goods.icon) - await bk.paste(icon) - await bk.paste(goods_image, (70, 0)) - n = 0 - _w = 650 - # 添加限时图标和时间 - if goods.goods_limit_time > 0: - n += 140 - _limit_time_logo = BuildImage( - 40, 40, background=f"{IMAGE_PATH}/other/time.png" - ) - await bk.paste(_limit_time_logo, (_w + 50, 0)) - _time_img = await BuildImage.build_text_image("限时!", size=23) - await bk.paste( - _time_img, - (_w + 90, 10), - ) - limit_time = time.strftime( - "%Y-%m-%d %H:%M", time.localtime(goods.goods_limit_time) - ).split() - y_m_d = limit_time[0] - _h_m = limit_time[1].split(":") - h_m = f"{_h_m[0]}时 {_h_m[1]}分" - await bk.text((_w + 55, 38), str(y_m_d)) - await bk.text((_w + 65, 57), str(h_m)) - _w += 140 - if goods.goods_discount != 1: - n += 140 - _discount_logo = BuildImage( - 30, 30, background=f"{IMAGE_PATH}/other/discount.png" - ) - await bk.paste(_discount_logo, (_w + 50, 10)) - _tmp = await BuildImage.build_text_image("折扣!", size=23) - await bk.paste(_tmp, (_w + 90, 15)) - _tmp = await BuildImage.build_text_image( - f"{10 * goods.goods_discount:.1f} 折", - size=30, - font_color=(85, 156, 75), - ) - await bk.paste(_tmp, (_w + 50, 44)) - _w += 140 - if goods.daily_limit != 0: - n += 140 - _daily_limit_logo = BuildImage( - 35, 35, background=f"{IMAGE_PATH}/other/daily_limit.png" - ) - await bk.paste(_daily_limit_logo, (_w + 50, 10)) - _tmp = await BuildImage.build_text_image( - "限购!", - size=23, - ) - await bk.paste(_tmp, (_w + 90, 20)) - _tmp = await BuildImage.build_text_image(f"{goods.daily_limit}", size=30) - await bk.paste(_tmp, (_w + 72, 45)) - total_n = max(total_n, n) - if n: - await bk.line((650, -1, 650 + n, -1), "#a29ad6", 5) - # await bk.aline((650, 80, 650 + n, 80), "#a29ad6", 5) - - # 添加限时图标和时间 - image_list.append(bk) - # await A.apaste(bk, (0, current_h), True) - # current_h += 90 - current_h = 0 - h = sum(img.height + 10 for img in image_list) or 400 - A = BuildImage(1100, h, color="#f9f6f2") - for img in image_list: - await A.paste(img, (0, current_h)) - current_h += img.height + 10 - w = 950 - if total_n: - w += total_n - h = A.height + 230 + 100 - h = max(h, 1000) - shop_logo = BuildImage(100, 100, background=f"{IMAGE_PATH}/other/shop_text.png") - shop = BuildImage(w, h, font_size=20, color="#f9f6f2") - await shop.paste(A, (20, 230)) - await shop.paste(shop_logo, (450, 30)) - tip = "注【通过 购买道具 序号 或者 商品名称 购买】" - await shop.text( - ( - int((1000 - shop.getsize(tip)[0]) / 2), - 170, - ), - "注【通过 序号 或者 商品名称 购买】", - ) - await shop.text( - (20, h - 100), - "神秘药水\t\t售价:9999999金币\n\t\t鬼知道会有什么效果~", - ) - return shop.pic2bytes() diff --git a/zhenxun/builtin_plugins/sign_in/__init__.py b/zhenxun/builtin_plugins/sign_in/__init__.py index 0986e476..3dd1863c 100644 --- a/zhenxun/builtin_plugins/sign_in/__init__.py +++ b/zhenxun/builtin_plugins/sign_in/__init__.py @@ -84,12 +84,6 @@ __plugin_meta__ = PluginMetadata( default_value=0.05, type=float, ), - RegisterConfig( - key="IMAGE_STYLE", - value="zhenxun", - help="签到图片样式, [normal, zhenxun]", - default_value="zhenxun", - ), ], limits=[PluginCdBlock()], ).to_dict(), diff --git a/zhenxun/builtin_plugins/sign_in/config.py b/zhenxun/builtin_plugins/sign_in/config.py index d2016c5b..e5ae1639 100644 --- a/zhenxun/builtin_plugins/sign_in/config.py +++ b/zhenxun/builtin_plugins/sign_in/config.py @@ -1,12 +1,6 @@ from zhenxun.configs.path_config import IMAGE_PATH -SIGN_RESOURCE_PATH = IMAGE_PATH / "sign" / "sign_res" SIGN_TODAY_CARD_PATH = IMAGE_PATH / "sign" / "today_card" -SIGN_BORDER_PATH = SIGN_RESOURCE_PATH / "border" -SIGN_BACKGROUND_PATH = SIGN_RESOURCE_PATH / "background" - -SIGN_BORDER_PATH.mkdir(exist_ok=True, parents=True) -SIGN_BACKGROUND_PATH.mkdir(exist_ok=True, parents=True) lik2relation = { diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py index 910b90d8..15a54714 100644 --- a/zhenxun/builtin_plugins/sign_in/utils.py +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -1,28 +1,22 @@ from datetime import datetime -from io import BytesIO import os from pathlib import Path import random +import aiofiles import nonebot from nonebot.drivers import Driver -from nonebot_plugin_htmlrender import template_to_pic from nonebot_plugin_uninfo import Uninfo import pytz from zhenxun.configs.config import BotConfig, Config -from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_user import SignUser -from zhenxun.utils.http_utils import AsyncHttpx -from zhenxun.utils.image_utils import BuildImage +from zhenxun.services import renderer_service from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.platform import PlatformUtils from .config import ( - SIGN_BACKGROUND_PATH, - SIGN_BORDER_PATH, - SIGN_RESOURCE_PATH, SIGN_TODAY_CARD_PATH, level2attitude, lik2level, @@ -57,9 +51,7 @@ LG_MESSAGE = [ @PriorityLifecycle.on_startup(priority=5) async def init_image(): - SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True) SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True) - # await generate_progress_bar_pic() clear_sign_data_pic() @@ -88,290 +80,54 @@ async def get_card( 返回: Path: 卡片路径 """ - await generate_progress_bar_pic() user_id = user.user_id date = datetime.now().date() _type = "view" if is_card_view else "sign" file_name = f"{user_id}_{_type}_{date}.png" - view_name = f"{user_id}_view_{date}.png" - card_file = Path(SIGN_TODAY_CARD_PATH) / file_name + card_file = SIGN_TODAY_CARD_PATH / file_name + if card_file.exists(): - return IMAGE_PATH / "sign" / "today_card" / file_name + return card_file + if add_impression == -1: - card_file = Path(SIGN_TODAY_CARD_PATH) / view_name - if card_file.exists(): - return card_file + view_name = f"{user_id}_view_{date}.png" + view_card_file = SIGN_TODAY_CARD_PATH / view_name + if view_card_file.exists(): + return view_card_file is_card_view = True - return ( - await _generate_html_card( - user, session, nickname, add_impression, gold, gift, is_double, is_card_view - ) - if base_config.get("IMAGE_STYLE") == "zhenxun" - else await _generate_card( - user, session, nickname, add_impression, gold, gift, is_double, is_card_view - ) + + return await _generate_html_card( + user, session, nickname, add_impression, gold, gift, is_double, is_card_view ) -async def _generate_card( - user: SignUser, - session: Uninfo, - nickname: str, - add_impression: float, - gold: int | None, - gift: str, - is_double: bool = False, - is_card_view: bool = False, -) -> Path: - """生成签到卡片 - - 参数: - user: SignUser - session: Uninfo - nickname: 用户昵称 - add_impression: 新增的好感度 - gold: 金币 - gift: 礼物 - is_double: 是否触发双倍. - is_card_view: 是否展示好感度卡片. - - 返回: - Path: 卡片路径 - """ - ava_bk = BuildImage(140, 140, (255, 255, 255, 0)) - ava_border = BuildImage( - 140, - 140, - background=SIGN_BORDER_PATH / "ava_border_01.png", - ) - if session.user.avatar and ( - byt := await AsyncHttpx.get_content(session.user.avatar) - ): - ava = BuildImage(107, 107, background=BytesIO(byt)) - else: - ava = BuildImage(107, 107, (0, 0, 0)) - await ava.circle() - await ava_bk.paste(ava, (19, 18)) - await ava_bk.paste(ava_border, center_type="center") - impression = float(user.impression) - info_img = BuildImage(250, 150, color=(255, 255, 255, 0), font_size=15) - level, next_impression, previous_impression = get_level_and_next_impression( - impression - ) - interpolation = next_impression - impression - await info_img.text((0, 0), f"· 好感度等级:{level} [{lik2relation[level]}]") - await info_img.text( - (0, 20), f"· {BotConfig.self_nickname}对你的态度:{level2attitude[level]}" - ) - await info_img.text((0, 40), f"· 距离升级还差 {interpolation:.2f} 好感度") - - bar_bk = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar_white.png") - bar = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar.png") - ratio = 1 - (next_impression - impression) / (next_impression - previous_impression) - if next_impression == 0: - ratio = 0 - await bar.resize(width=int(bar.width * ratio) or 1, height=bar.height) - await bar_bk.paste(bar) - font_size = 20 if "好感度双倍加持卡" in gift else 30 - gift_border = BuildImage( - 270, - 100, - background=SIGN_BORDER_PATH / "gift_border_02.png", - font_size=font_size, - ) - await gift_border.text((0, 0), gift, center_type="center") - - bk = BuildImage( - 876, - 424, - background=SIGN_BACKGROUND_PATH - / random.choice(os.listdir(SIGN_BACKGROUND_PATH)), - font_size=25, - ) - A = BuildImage(876, 274, background=SIGN_RESOURCE_PATH / "white.png") - line = BuildImage(2, 180, color="black") - await A.transparent(2) - await A.paste(ava_bk, (25, 80)) - await A.paste(line, (200, 70)) - nickname_img = await BuildImage.build_text_image( - nickname, size=50, font_color=(255, 255, 255) - ) - user_console = await user.user_console - if user_console and user_console.uid is not None: - uid = f"{user_console.uid}".rjust(12, "0") - uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}" - else: - uid = "XXXX XXXX XXXX" - uid_img = await BuildImage.build_text_image( - f"UID: {uid}", size=30, font_color=(255, 255, 255) - ) - image1 = await bk.build_text_image("Accumulative check-in for", bk.font, size=30) - image2 = await bk.build_text_image("days", bk.font, size=30) - sign_day_img = await BuildImage.build_text_image( - f"{user.sign_count}", size=40, font_color=(211, 64, 33) - ) - tip_width = image1.width + image2.width + sign_day_img.width + 60 - tip_height = max([image1.height, image2.height, sign_day_img.height]) - tip_image = BuildImage(tip_width, tip_height, (255, 255, 255, 0)) - await tip_image.paste(image1, (0, 7)) - await tip_image.paste(sign_day_img, (image1.width + 7, 0)) - await tip_image.paste(image2, (image1.width + sign_day_img.width + 15, 7)) - - lik_text1_img = await BuildImage.build_text_image("当前", size=20) - lik_text2_img = await BuildImage.build_text_image( - f"好感度:{user.impression:.2f}", size=30 - ) - watermark = await BuildImage.build_text_image( - f"{BotConfig.self_nickname}@{datetime.now().year}", - size=15, - font_color=(155, 155, 155), - ) - today_data = BuildImage(300, 300, color=(255, 255, 255, 0), font_size=20) - if is_card_view: - today_sign_text_img = await BuildImage.build_text_image("", size=30) - value_list = ( - await SignUser.annotate() - .order_by("-impression") - .values_list("user_id", flat=True) - ) - index = value_list.index(user.user_id) + 1 # type: ignore - rank_img = await BuildImage.build_text_image( - f"* 好感度排名第 {index} 位", size=30 - ) - await A.paste(rank_img, ((A.width - rank_img.width - 32), 20)) - last_log = ( - await SignLog.filter(user_id=user.user_id).order_by("create_time").first() - ) - last_date = "从未" - if last_log: - last_date = last_log.create_time.astimezone( - pytz.timezone("Asia/Shanghai") - ).date() - await today_data.text( - (0, 0), - f"上次签到日期:{last_date}", - ) - await today_data.text((0, 25), f"总金币:{gold}") - default_setu_prob = ( - Config.get_config("send_setu", "INITIAL_SETU_PROBABILITY") * 100 # type: ignore - ) - setu_prob = ( - default_setu_prob + float(user.impression) if user.impression < 100 else 100 - ) - await today_data.text( - (0, 50), - f"色图概率:{setu_prob:.2f}%", - ) - await today_data.text((0, 75), f"开箱次数:{(20 + int(user.impression / 3))}") - _type = "view" - else: - await A.paste(gift_border, (570, 140)) - today_sign_text_img = await BuildImage.build_text_image("今日签到", size=30) - if is_double: - await today_data.text((0, 0), f"好感度 + {add_impression / 2:.2f} × 2") - else: - await today_data.text((0, 0), f"好感度 + {add_impression:.2f}") - await today_data.text((0, 25), f"金币 + {gold}") - _type = "sign" - current_date = datetime.now() - current_datetime_str = current_date.strftime("%Y-%m-%d %a %H:%M:%S") - date = current_date.date() - date_img = await BuildImage.build_text_image( - f"时间:{current_datetime_str}", size=20 - ) - await bk.paste(nickname_img, (30, 15)) - await bk.paste(uid_img, (30, 85)) - await bk.paste(A, (0, 150)) - await bk.paste(tip_image, (10, 167)) - await bk.paste(date_img, (220, 370)) - await bk.paste(lik_text1_img, (220, 240)) - await bk.paste(lik_text2_img, (262, 234)) - await bk.paste(bar_bk, (225, 275)) - await bk.paste(info_img, (220, 305)) - await bk.paste(today_sign_text_img, (550, 180)) - await bk.paste(today_data, (580, 220)) - await bk.paste(watermark, (15, 400)) - await bk.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png") - return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png" - - -async def generate_progress_bar_pic(): - """ - 初始化进度条图片 - """ - bar_white_file = SIGN_RESOURCE_PATH / "bar_white.png" - if bar_white_file.exists(): - return - - bg_2 = (254, 1, 254) - bg_1 = (0, 245, 246) - - bk = BuildImage(1000, 50) - img_x = BuildImage(50, 50, color=bg_2) - await img_x.circle() - await img_x.crop((25, 0, 50, 50)) - img_y = BuildImage(50, 50, color=bg_1) - await img_y.circle() - await img_y.crop((0, 0, 25, 50)) - A = BuildImage(950, 50) - width, height = A.size - - step_r = (bg_2[0] - bg_1[0]) / width - step_g = (bg_2[1] - bg_1[1]) / width - step_b = (bg_2[2] - bg_1[2]) / width - - for y in range(width): - bg_r = round(bg_1[0] + step_r * y) - bg_g = round(bg_1[1] + step_g * y) - bg_b = round(bg_1[2] + step_b * y) - for x in range(height): - await A.point((y, x), fill=(bg_r, bg_g, bg_b)) - await bk.paste(img_y, (0, 0)) - await bk.paste(A, (25, 0)) - await bk.paste(img_x, (975, 0)) - await bk.save(SIGN_RESOURCE_PATH / "bar.png") - - A = BuildImage(950, 50) - bk = BuildImage(1000, 50) - img_x = BuildImage(50, 50) - await img_x.circle() - await img_x.crop((25, 0, 50, 50)) - img_y = BuildImage(50, 50) - await img_y.circle() - await img_y.crop((0, 0, 25, 50)) - await bk.paste(img_y, (0, 0)) - await bk.paste(A, (25, 0)) - await bk.paste(img_x, (975, 0)) - await bk.save(bar_white_file) - - -def get_level_and_next_impression(impression: float) -> tuple[str, int | float, int]: +def get_level_and_next_impression(impression: float) -> tuple[int, int | float, int]: """获取当前好感等级与下一等级的差距 参数: impression: 好感度 返回: - tuple[str, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求 + tuple[int, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求 """ keys = list(lik2level.keys()) - level, next_impression, previous_impression = ( - lik2level[keys[-1]], + level_int, next_impression, previous_impression = ( + int(lik2level[keys[-1]]), keys[-2], keys[-1], ) for i in range(len(keys)): if impression >= keys[i]: - level, next_impression, previous_impression = ( - lik2level[keys[i]], + level_int, next_impression, previous_impression = ( + int(lik2level[keys[i]]), keys[i - 1], keys[i], ) if i == 0: next_impression = impression break - return level, next_impression, previous_impression + return level_int, next_impression, previous_impression def clear_sign_data_pic(): @@ -394,7 +150,7 @@ async def _generate_html_card( is_double: bool = False, is_card_view: bool = False, ) -> Path: - """生成签到卡片 + """使用渲染服务生成签到卡片 参数: user: SignUser @@ -404,79 +160,130 @@ async def _generate_html_card( gold: 金币 gift: 礼物 is_double: 是否触发双倍. - is_card_view: 是否展示好感度卡片. + is_card_view: 是否为卡片视图. 返回: Path: 卡片路径 """ + now = datetime.now() + date = now.date() + _type = "view" if is_card_view else "sign" + file_name = f"{user.user_id}_{_type}_{date}.png" + card_file = SIGN_TODAY_CARD_PATH / file_name + + if card_file.exists(): + return card_file + impression = float(user.impression) user_console = await user.user_console - if user_console and user_console.uid is not None: - uid = f"{user_console.uid}".rjust(12, "0") - uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}" - else: - uid = "XXXX XXXX XXXX" + uid_str = ( + f"{user_console.uid:08}" + if user_console and user_console.uid is not None + else "XXXXXXXX" + ) + uid_formatted = f"{uid_str[:4]} {uid_str[4:]}" + level, next_impression, previous_impression = get_level_and_next_impression( impression ) - interpolation = next_impression - impression - message = f"{BotConfig.self_nickname}希望你开心!" - hour = datetime.now().hour - if hour > 6 and hour < 10: - message = random.choice(MORNING_MESSAGE) - elif hour >= 0 and hour < 6: - message = random.choice(LG_MESSAGE) - _impression = f"{add_impression}(×2)" if is_double else add_impression - process = 1 - (next_impression - impression) / ( - next_impression - previous_impression + + attitude = level2attitude.get(str(level), "未知") + interpolation_val = max(0, next_impression - impression) + interpolation = f"{interpolation_val:.2f}" + + denominator = next_impression - previous_impression + progress = ( + 100.0 + if denominator == 0 + else min(100.0, ((impression - previous_impression) / denominator) * 100) ) - now = datetime.now() - data = { - "ava_url": PlatformUtils.get_user_avatar_url( + + hour = now.hour + if 6 < hour < 10: + bot_message = random.choice(MORNING_MESSAGE) + elif 0 <= hour < 6: + bot_message = random.choice(LG_MESSAGE) + else: + bot_message = f"{BotConfig.self_nickname}希望你开心!" + + temperature = random.randint(1, 40) + weather_icon_name = f"{random.randint(0, 11)}.png" + tag_icon_name = f"{random.randint(0, 5)}.png" + + user_info = { + "nickname": nickname, + "uid_str": uid_formatted, + "avatar_url": PlatformUtils.get_user_avatar_url( user.user_id, PlatformUtils.get_platform(session), session.self_id - ), - "name": nickname, - "uid": uid, - "sign_count": f"{user.sign_count}", - "message": f"{BotConfig.self_nickname}说: {message}", - "cur_impression": f"{impression:.2f}", - "impression": f"好感度+{_impression}", - "gold": f"金币+{gold}", - "gift": gift, - "level": f"{level} [{lik2relation[level]}]", - "attitude": f"对你的态度: {level2attitude[level]}", - "interpolation": f"{interpolation:.2f}", - "heart2": [1 for _ in range(int(level))], - "heart1": [1 for _ in range(len(lik2level) - int(level) - 1)], - "process": process * 100, - "date": str(now.replace(microsecond=0)), - "font_size": 45, + ) + or "", + "sign_count": user.sign_count, } - if len(nickname) > 6: - data["font_size"] = 27 - _type = "sign" + + favorability_info = { + "current": impression, + "level": level, + "next_level_at": next_impression, + "previous_level_at": previous_impression, + } + + reward_info = None + rank = None + total_gold = None + last_sign_date_str = None + if is_card_view: - _type = "view" value_list = ( await SignUser.annotate() .order_by("-impression") .values_list("user_id", flat=True) ) - index = value_list.index(user.user_id) + 1 # type: ignore - data["impression"] = f"好感度排名第 {index} 位" - data["gold"] = f"总金币:{gold}" - data["gift"] = "" - pic = await template_to_pic( - template_path=str((TEMPLATE_PATH / "sign").absolute()), - template_name="main.html", - templates={"data": data}, - pages={ - "viewport": {"width": 465, "height": 926}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - image = BuildImage.open(pic) - date = now.date() - await image.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png") - return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png" + rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0 + total_gold = user_console.gold if user_console else 0 + + last_log = ( + await SignLog.filter(user_id=user.user_id).order_by("-create_time").first() + ) + last_date = "从未" + if last_log: + last_date = str( + last_log.create_time.astimezone(pytz.timezone("Asia/Shanghai")).date() + ) + last_sign_date_str = f"上次签到:{last_date}" + + else: + reward_info = { + "impression_added": add_impression, + "gold_added": gold or 0, + "gift_received": gift, + "is_double": is_double, + } + + page_info = { + "date_str": str(now.replace(microsecond=0)), + "weather_icon_name": weather_icon_name, + "temperature": temperature, + "tag_icon_name": tag_icon_name, + } + + card_data = { + "is_card_view": is_card_view, + "user": user_info, + "favorability": favorability_info, + "reward": reward_info, + "page": page_info, + "bot_message": bot_message, + "attitude": attitude, + "interpolation": interpolation, + "progress": progress, + "rank": rank, + "total_gold": total_gold, + "last_sign_date_str": last_sign_date_str, + } + + image_bytes = await renderer_service.render("pages/builtin/sign", data=card_data) + + async with aiofiles.open(card_file, "wb") as f: + await f.write(image_bytes) + + return card_file diff --git a/zhenxun/builtin_plugins/superuser/reload_setting.py b/zhenxun/builtin_plugins/superuser/reload_setting.py index 019e4c37..98cce1b1 100644 --- a/zhenxun/builtin_plugins/superuser/reload_setting.py +++ b/zhenxun/builtin_plugins/superuser/reload_setting.py @@ -8,6 +8,7 @@ from nonebot_plugin_session import EventSession from zhenxun.configs.config import Config from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger +from zhenxun.services.renderer import renderer_service from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils @@ -55,6 +56,9 @@ _matcher = on_alconna( async def _(session: EventSession, arparma: Arparma): Config.reload() logger.debug("自动重载配置文件", arparma.header_result, session=session) + + await renderer_service.reload_theme() + await MessageUtils.build_message("重载完成!").send(reply_to=True) diff --git a/zhenxun/builtin_plugins/superuser/super_help/__init__.py b/zhenxun/builtin_plugins/superuser/super_help.py similarity index 50% rename from zhenxun/builtin_plugins/superuser/super_help/__init__.py rename to zhenxun/builtin_plugins/superuser/super_help.py index 258af8db..2136790e 100644 --- a/zhenxun/builtin_plugins/superuser/super_help/__init__.py +++ b/zhenxun/builtin_plugins/superuser/super_help.py @@ -3,17 +3,13 @@ from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import Alconna, Arparma, on_alconna from nonebot_plugin_session import EventSession -from zhenxun.configs.config import Config -from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.help_service import create_plugin_help_image from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.exception import EmptyError from zhenxun.utils.message import MessageUtils -from .config import SUPERUSER_HELP_IMAGE -from .normal_help import build_help -from .zhenxun_help import build_html_help - __plugin_meta__ = PluginMetadata( name="超级用户帮助", description="超级用户帮助", @@ -24,17 +20,18 @@ __plugin_meta__ = PluginMetadata( author="HibiKier", version="0.1", plugin_type=PluginType.SUPERUSER, - configs=[ - RegisterConfig( - key="type", - value="zhenxun", - help="超级用户帮助样式,normal, zhenxun", - default_value="zhenxun", - ) - ], ).to_dict(), ) + +async def build_html_help() -> bytes: + """构建超级用户帮助图片""" + return await create_plugin_help_image( + plugin_types=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN], + page_title="超级用户帮助手册", + ) + + _matcher = on_alconna( Alconna("超级用户帮助"), permission=SUPERUSER, @@ -45,15 +42,11 @@ _matcher = on_alconna( @_matcher.handle() async def _(session: EventSession, arparma: Arparma): - if not SUPERUSER_HELP_IMAGE.exists(): - try: - if Config.get_config("admin_help", "type") == "zhenxun": - await build_html_help() - else: - await build_help() - except EmptyError: - await MessageUtils.build_message("当前超级用户帮助为空...").finish( - reply_to=True - ) - await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send() + try: + image_bytes = await build_html_help() + await MessageUtils.build_message(image_bytes).send() + except EmptyError: + await MessageUtils.build_message("当前超级用户帮助为空...").finish( + reply_to=True + ) logger.info("查看超级用户帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/superuser/super_help/config.py b/zhenxun/builtin_plugins/superuser/super_help/config.py deleted file mode 100644 index 5f5371d9..00000000 --- a/zhenxun/builtin_plugins/superuser/super_help/config.py +++ /dev/null @@ -1,23 +0,0 @@ -from nonebot.plugin import PluginMetadata -from pydantic import BaseModel - -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.models.plugin_info import PluginInfo - -SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png" -if SUPERUSER_HELP_IMAGE.exists(): - SUPERUSER_HELP_IMAGE.unlink() - - -class PluginData(BaseModel): - """ - 插件信息 - """ - - plugin: PluginInfo - """插件信息""" - metadata: PluginMetadata - """元数据""" - - class Config: - arbitrary_types_allowed = True diff --git a/zhenxun/builtin_plugins/superuser/super_help/normal_help.py b/zhenxun/builtin_plugins/superuser/super_help/normal_help.py deleted file mode 100644 index 7407e9e3..00000000 --- a/zhenxun/builtin_plugins/superuser/super_help/normal_help.py +++ /dev/null @@ -1,127 +0,0 @@ -from nonebot.plugin import PluginMetadata -from PIL.ImageFont import FreeTypeFont - -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.models.task_info import TaskInfo -from zhenxun.services.log import logger -from zhenxun.utils._build_image import BuildImage -from zhenxun.utils.image_utils import build_sort_image, group_image, text2image - -from .config import SUPERUSER_HELP_IMAGE -from .utils import get_plugins - - -async def build_usage_des_image( - metadata: PluginMetadata, -) -> tuple[BuildImage | None, BuildImage | None]: - """构建用法和描述图片 - - 参数: - metadata: PluginMetadata - - 返回: - tuple[BuildImage | None, BuildImage | None]: 用法和描述图片 - """ - usage = None - description = None - if metadata.usage: - usage = await text2image( - metadata.usage, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - if metadata.description: - description = await text2image( - metadata.description, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - return usage, description - - -async def build_image( - plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont -) -> BuildImage: - """构建帮助图片 - - 参数: - plugin: PluginInfo - metadata: PluginMetadata - font: FreeTypeFont - - 返回: - BuildImage: 帮助图片 - - """ - usage, description = await build_usage_des_image(metadata) - width = 0 - height = 100 - if usage: - width = usage.width - height += usage.height - if description and description.width > width: - width = description.width - height += description.height - font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font) - if font_width > width: - width = font_width - A = BuildImage(width + 30, height + 120, "#EAEDF2") - await A.text((15, 10), f"{plugin.name}[{plugin.level}]") - await A.text((15, 70), "简介:") - if not description: - description = BuildImage(A.width - 30, 30, (255, 255, 255)) - await description.circle_corner(10) - await A.paste(description, (15, 100)) - if not usage: - usage = BuildImage(A.width - 30, 30, (255, 255, 255)) - await usage.circle_corner(10) - await A.text((15, description.height + 115), "用法:") - await A.paste(usage, (15, description.height + 145)) - await A.circle_corner(10) - return A - - -async def build_help(): - """构造超级用户帮助图片 - - 返回: - BuildImage: 超级用户帮助图片 - """ - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - image_list = [] - for data in await get_plugins(): - plugin = data.plugin - metadata = data.metadata - try: - A = await build_image(plugin, metadata, font) - image_list.append(A) - except Exception as e: - logger.warning( - f"获取群超级用户插件 {plugin.module}: {plugin.name} 设置失败...", - "超级用户帮助", - e=e, - ) - if task_list := await TaskInfo.all(): - task_str = "\n".join([task.name for task in task_list]) - task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str - task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) - await task_image.circle_corner(10) - A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") - await A.text((25, 10), "被动技能") - await A.paste(task_image, (25, 50)) - await A.circle_corner(10) - image_list.append(A) - image_group, _ = group_image(image_list) - A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) - text = await BuildImage.build_text_image( - "群超级用户帮助", - size=40, - ) - tip = await BuildImage.build_text_image( - "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" - ) - await A.paste(text, (50, 30)) - await A.paste(tip, (50, 90)) - await A.save(SUPERUSER_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/superuser/super_help/utils.py b/zhenxun/builtin_plugins/superuser/super_help/utils.py deleted file mode 100644 index 201687ec..00000000 --- a/zhenxun/builtin_plugins/superuser/super_help/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import nonebot - -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.utils.enum import PluginType -from zhenxun.utils.exception import EmptyError - -from .config import PluginData - - -async def get_plugins() -> list[PluginData]: - """获取插件数据""" - plugin_list = await PluginInfo.filter( - plugin_type__in=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN] - ).all() - data_list = [] - for plugin in plugin_list: - if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): - if _plugin.metadata: - data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata)) - if not data_list: - raise EmptyError() - return data_list diff --git a/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py b/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py deleted file mode 100644 index ddd4bb05..00000000 --- a/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py +++ /dev/null @@ -1,60 +0,0 @@ -from nonebot_plugin_htmlrender import template_to_pic - -from zhenxun.configs.config import BotConfig -from zhenxun.configs.path_config import TEMPLATE_PATH -from zhenxun.models.task_info import TaskInfo -from zhenxun.utils._build_image import BuildImage - -from .config import SUPERUSER_HELP_IMAGE -from .utils import get_plugins - - -async def get_task() -> dict[str, str] | None: - """获取被动技能帮助""" - if task_list := await TaskInfo.all(): - return { - "name": "被动技能", - "description": "控制群组中的被动技能状态", - "usage": "通过 开启/关闭群被动 来控制群被动
" - + " 示例:开启/关闭群被动早晚安
----------
" - + "
".join([task.name for task in task_list]), - } - return None - - -async def build_html_help(): - """构建帮助图片""" - plugins = await get_plugins() - plugin_list = [] - for data in plugins: - if data.metadata.extra: - if superuser_help := data.metadata.extra.get("superuser_help"): - data.metadata.usage += f"
以下为超级用户额外命令
{superuser_help}" - plugin_list.append( - { - "name": data.plugin.name, - "description": data.metadata.description.replace("\n", "
"), - "usage": data.metadata.usage.replace("\n", "
"), - } - ) - if task := await get_task(): - plugin_list.append(task) - plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"])) - pic = await template_to_pic( - template_path=str((TEMPLATE_PATH / "help").absolute()), - template_name="main.html", - templates={ - "data": { - "plugin_list": plugin_list, - "nickname": BotConfig.self_nickname, - "help_name": "超级用户", - } - }, - pages={ - "viewport": {"width": 824, "height": 10}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - result = await BuildImage.open(pic).resize(0.5) - await result.save(SUPERUSER_HELP_IMAGE) diff --git a/zhenxun/configs/path_config.py b/zhenxun/configs/path_config.py index e100ca2d..11d47cba 100644 --- a/zhenxun/configs/path_config.py +++ b/zhenxun/configs/path_config.py @@ -15,7 +15,9 @@ DATA_PATH = Path() / "data" # 临时数据路径 TEMP_PATH = Path() / "resources" / "temp" # 网页模板路径 -TEMPLATE_PATH = Path() / "resources" / "template" +THEMES_PATH = Path() / "resources" / "themes" +# [新增] UI渲染服务的统一缓存路径 +UI_CACHE_PATH = TEMP_PATH / "ui_cache" IMAGE_PATH.mkdir(parents=True, exist_ok=True) @@ -25,3 +27,4 @@ LOG_PATH.mkdir(parents=True, exist_ok=True) FONT_PATH.mkdir(parents=True, exist_ok=True) DATA_PATH.mkdir(parents=True, exist_ok=True) TEMP_PATH.mkdir(parents=True, exist_ok=True) +UI_CACHE_PATH.mkdir(parents=True, exist_ok=True) diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index 9fde890c..6b2b5bb5 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -43,6 +43,7 @@ from .llm import ( ) from .log import logger from .plugin_init import PluginInit, PluginInitManager +from .renderer import renderer_service from .scheduler import scheduler_manager __all__ = [ @@ -69,6 +70,7 @@ __all__ = [ "list_available_models", "list_embedding_models", "logger", + "renderer_service", "scheduler_manager", "search", "set_global_default_model_name", diff --git a/zhenxun/services/help_service.py b/zhenxun/services/help_service.py new file mode 100644 index 00000000..41c783e5 --- /dev/null +++ b/zhenxun/services/help_service.py @@ -0,0 +1,109 @@ +from collections import defaultdict + +import nonebot +from nonebot.plugin import PluginMetadata +from pydantic import BaseModel + +from zhenxun.configs.config import BotConfig +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.task_info import TaskInfo +from zhenxun.ui import HelpCategory, HelpItem, PluginHelpPageBuilder +from zhenxun.utils.common_utils import format_usage_for_markdown +from zhenxun.utils.enum import PluginType + + +class PluginData(BaseModel): + plugin: PluginInfo + metadata: PluginMetadata + + class Config: + arbitrary_types_allowed = True + + +async def _get_plugins_by_types(plugin_types: list[PluginType]) -> list[PluginData]: + """根据指定的插件类型列表获取插件数据""" + plugin_list = await PluginInfo.filter(plugin_type__in=plugin_types).all() + data_list = [] + for plugin in plugin_list: + if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): + if _plugin.metadata: + data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata)) + return data_list + + +async def _get_task_category() -> dict: + """获取被动技能帮助类别""" + task_items = [] + if task_list := await TaskInfo.all(): + task_names = "\n".join([task.name for task in task_list]) + task_items.append( + { + "name": "被动技能", + "description": "控制群组中的被动技能状态", + "usage": "通过 开启/关闭群被动 来控制群被动\n" + + " 示例:开启/关闭群被动早晚安\n 示例:开启/关闭全部群被动" + + " \n ---------- \n " + + task_names, + } + ) + + return { + "title": "被动技能管理", + "icon_svg_path": "M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z", + "items": task_items, + } + + +async def create_plugin_help_image( + plugin_types: list[PluginType], page_title: str +) -> bytes: + """ + 一个通用的函数,用于创建插件帮助图片。 + + 参数: + plugin_types: 要包含在帮助中的插件类型列表。 + page_title: 生成图片的标题。 + + 返回: + bytes: 生成的图片字节流。 + """ + plugins_data = await _get_plugins_by_types(plugin_types) + + grouped_plugins = defaultdict(list) + for data in plugins_data: + menu_type = data.plugin.menu_type or "功能" + grouped_plugins[menu_type].append( + HelpItem( + name=data.plugin.name, + description=format_usage_for_markdown(data.metadata.description), + usage=format_usage_for_markdown(data.metadata.usage), + ) + ) + + builder = PluginHelpPageBuilder( + bot_nickname=BotConfig.self_nickname, page_title=page_title + ) + + for menu_type, items in grouped_plugins.items(): + builder.add_category( + 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", + items=sorted(items, key=lambda x: x.name), + ) + ) + + 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( + HelpCategory( + title=task_category_data["title"], + icon_svg_path=task_category_data["icon_svg_path"], + items=task_items, + ) + ) + + image_bytes = await builder.build() + + return image_bytes diff --git a/zhenxun/services/renderer/__init__.py b/zhenxun/services/renderer/__init__.py new file mode 100644 index 00000000..5958fcdd --- /dev/null +++ b/zhenxun/services/renderer/__init__.py @@ -0,0 +1,38 @@ +""" +图片渲染服务 + +提供一个统一的、可扩展的接口来将结构化数据渲染成图片。 +""" + +from zhenxun.configs.config import Config +from zhenxun.utils.manager.priority_manager import PriorityLifecycle + +from .service import RendererService + +Config.add_plugin_config( + "UI", + "THEME", + "default", + help="设置渲染服务使用的全局主题名称 (对应 resources/themes/下的目录名)", + default_value="default", + type=str, +) +Config.add_plugin_config( + "UI", + "CACHE", + True, + help="是否为渲染服务生成的图片启用文件缓存", + default_value=True, + type=bool, +) + +renderer_service = RendererService() + + +@PriorityLifecycle.on_startup(priority=10) +async def _init_renderer_service(): + """在Bot启动时预热渲染服务,扫描并加载所有模板。""" + await renderer_service.initialize() + + +__all__ = ["renderer_service"] diff --git a/zhenxun/services/renderer/engines.py b/zhenxun/services/renderer/engines.py new file mode 100644 index 00000000..8c6d0a4b --- /dev/null +++ b/zhenxun/services/renderer/engines.py @@ -0,0 +1,221 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +import aiofiles +from jinja2 import Environment +import markdown +from nonebot_plugin_htmlrender import html_to_pic +from pydantic import BaseModel + +from zhenxun.configs.path_config import THEMES_PATH +from zhenxun.services.log import logger + +from .models import Theme + +THEME_PATH = THEMES_PATH +RESOURCE_ROOT = THEMES_PATH.parent + + +class BaseEngine(ABC): + """渲染引擎的抽象基类。""" + + @abstractmethod + async def render( + self, + template_name: str, + data: BaseModel | dict | None, + theme: Theme, + jinja_env: "Environment | None" = None, + extra_css_paths: list[Path] | None = None, + custom_css_path: Path | None = None, + **kwargs, + ) -> bytes: + """所有引擎都必须实现的渲染方法。""" + pass + + +class BaseHtmlRenderingEngine(BaseEngine): + """ + 一个专门用于处理HTML到图片转换的引擎基类。 + 它使用模板方法模式,定义了渲染的固定流程, + 并将具体的HTML内容生成委托给子类的抽象方法 `get_html_content`。 + """ + + @abstractmethod + async def get_html_content( + self, + template_name: str, + data: BaseModel | dict | None, + theme: Theme, + jinja_env: "Environment", + extra_css_paths: list[Path] | None, + custom_css_path: Path | None, + frameless: bool, + **kwargs, + ) -> str: + """ + [抽象方法] 子类必须实现此方法以生成最终的HTML字符串。 + """ + pass + + async def render( + self, + template_name: str, + data: BaseModel | dict | None, + theme: Theme, + jinja_env: "Environment | None" = None, + extra_css_paths: list[Path] | None = None, + custom_css_path: Path | None = None, + **kwargs, + ) -> bytes: + """ + [通用渲染流程] 调用 `get_html_content` 获取HTML,然后调用 `html_to_pic` 生成图片 + """ + if not jinja_env: + raise ValueError("HTML渲染器需要一个有效的Jinja2环境实例。") + + frameless = kwargs.pop("frameless", False) + + html_content = await self.get_html_content( + template_name, + data, + theme, + jinja_env, + extra_css_paths, + custom_css_path, + frameless=frameless, + **kwargs, + ) + + base_url_for_browser = RESOURCE_ROOT.absolute().as_uri() + if not base_url_for_browser.endswith("/"): + base_url_for_browser += "/" + + pages_config = { + "viewport": kwargs.pop("viewport", {"width": 800, "height": 10}), + "base_url": base_url_for_browser, + } + + final_screenshot_kwargs = kwargs.copy() + final_screenshot_kwargs.update(pages_config) + + return await html_to_pic( + html=html_content, + template_path=base_url_for_browser, + **final_screenshot_kwargs, + ) + + +class HtmlRenderer(BaseHtmlRenderingEngine): + """使用 nonebot-plugin-htmlrender 渲染HTML模板的引擎。""" + + async def get_html_content( + self, + template_name: str, + data: BaseModel | dict | None, + theme: Theme, + jinja_env: "Environment", + extra_css_paths: list[Path] | None, + custom_css_path: Path | None, + frameless: bool, + **kwargs, + ) -> str: + def asset_loader(asset_path: str) -> str: + current_theme_asset = theme.assets_dir / asset_path + if current_theme_asset.exists(): + return current_theme_asset.relative_to(RESOURCE_ROOT).as_posix() + + default_theme_asset = theme.default_assets_dir / asset_path + if default_theme_asset.exists(): + return default_theme_asset.relative_to(RESOURCE_ROOT).as_posix() + + logger.warning( + f"资源文件在主题 '{theme.name}' 和 'default' 中均未找到: {asset_path}" + ) + return "" + + extra_css_content = "" + if extra_css_paths: + css_contents = [] + for path in extra_css_paths: + if path.exists(): + async with aiofiles.open(path, encoding="utf-8") as f: + css_contents.append(await f.read()) + extra_css_content = "\n".join(css_contents) + + template_context = { + "data": data, + "extra_css": extra_css_content, + "frameless": frameless, + "theme": { + "name": theme.name, + "palette": theme.palette, + "asset": asset_loader, + }, + } + + template = jinja_env.get_template(template_name) + return await template.render_async(**template_context) + + +class MarkdownEngine(BaseHtmlRenderingEngine): + """在服务端渲染 Markdown 为 HTML,然后截图的引擎。""" + + async def get_html_content( + self, + template_name: str, + data: BaseModel | dict | None, + theme: Theme, + jinja_env: "Environment", + extra_css_paths: list[Path] | None, + custom_css_path: Path | None, + frameless: bool, + **kwargs, + ) -> str: + if isinstance(data, BaseModel): + raw_md = getattr(data, "markdown", "") if hasattr(data, "markdown") else "" + else: + raw_md = (data or {}).get("markdown", "") + + md_html = markdown.markdown( + raw_md, + extensions=[ + "pymdownx.tasklist", + "tables", + "fenced_code", + "codehilite", + "mdx_math", + "pymdownx.tilde", + ], + extension_configs={"mdx_math": {"enable_dollar_delimiter": True}}, + ) + + final_css_content = "" + if custom_css_path and custom_css_path.exists(): + logger.debug(f"正在为 Markdown 渲染加载自定义样式: {custom_css_path}") + async with aiofiles.open(custom_css_path, encoding="utf-8") as f: + final_css_content = await f.read() + else: + css_paths = [ + theme.default_assets_dir / "css/markdown/github-light.css", + theme.default_assets_dir / "css/markdown/pygments-default.css", + ] + css_contents = [] + for path in css_paths: + if path.exists(): + async with aiofiles.open(path, encoding="utf-8") as f: + css_contents.append(await f.read()) + final_css_content = "\n".join(css_contents) + + template_context = { + "data": data, + "theme_css": theme.style_css, + "custom_style_css": final_css_content, + "md_html": md_html, + "extra_css": "", + "frameless": frameless, + "theme": {"name": theme.name}, + } + + template = jinja_env.get_template(template_name) + return await template.render_async(**template_context) diff --git a/zhenxun/services/renderer/models.py b/zhenxun/services/renderer/models.py new file mode 100644 index 00000000..78d23025 --- /dev/null +++ b/zhenxun/services/renderer/models.py @@ -0,0 +1,40 @@ +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="用于PIL渲染的调色板" + ) + 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')" + ) + schema_path: str | None = Field( + None, description="用于数据验证的Pydantic模型的Python导入路径" + ) + render_options: dict[str, Any] = Field( + default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)" + ) diff --git a/zhenxun/services/renderer/service.py b/zhenxun/services/renderer/service.py new file mode 100644 index 00000000..f76809cc --- /dev/null +++ b/zhenxun/services/renderer/service.py @@ -0,0 +1,489 @@ +import asyncio +from collections.abc import Callable, Generator +import hashlib +import json +from pathlib import Path + +import aiofiles +from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader +import markdown +from pydantic import BaseModel, ValidationError + +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH +from zhenxun.services.log import logger +from zhenxun.utils.exception import RenderingError + +from .engines import BaseEngine, HtmlRenderer, MarkdownEngine +from .models import TemplateManifest, Theme + +THEME_PATH = THEMES_PATH + + +class RendererService: + """图片渲染服务管理器。""" + + def __init__(self): + self._engines: dict[str, BaseEngine] = { + "html": HtmlRenderer(), + "markdown": MarkdownEngine(), + } + self._templates: dict[str, TemplateManifest] = {} + self._template_paths: dict[str, Path] = {} + self._plugin_template_paths: dict[str, Path] = {} + self._plugin_manifests: dict[str, TemplateManifest] = {} + self._init_lock = asyncio.Lock() + self._initialized = False + self._current_theme_data: Theme | None = None + self._jinja_environments: dict[str, Environment] = {} + + self._custom_filters: dict[str, Callable] = {} + self._custom_globals: dict[str, Callable] = {} + self._markdown_styles: dict[str, Path] = {} + + def register_template_namespace(self, namespace: str, path: Path): + """ + 为插件注册一个模板命名空间。 + + 参数: + namespace: 插件的唯一命名空间 (建议使用插件模块名)。 + path: 包含模板文件的目录路径。 + """ + if namespace in self._plugin_template_paths: + logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。") + if not path.is_dir(): + raise ValueError(f"提供的路径 '{path}' 不是一个有效的目录。") + + self._plugin_template_paths[namespace] = path + logger.debug(f"已注册模板命名空间 '{namespace}' -> '{path}'") + + def register_markdown_style(self, name: str, path: Path): + """ + [新增] 为 Markdown 渲染器注册一个具名样式。 + + 参数: + name: 样式的唯一名称 (建议使用 '插件名:样式名' 格式以避免冲突)。 + path: 指向 CSS 文件的 Path 对象。 + """ + if name in self._markdown_styles: + logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。") + if not path.is_file(): + raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。") + self._markdown_styles[name] = path + logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'") + + def filter(self, name: str) -> Callable: + """ + 装饰器:注册一个自定义 Jinja2 过滤器。 + + 参数: + name: 过滤器在模板中的调用名称。为避免冲突,强烈建议使用 + '插件名_过滤器名' 的格式。 + """ + + def decorator(func: Callable) -> Callable: + if name in self._custom_filters: + logger.warning(f"Jinja2 过滤器 '{name}' 已被注册,将被覆盖。") + self._custom_filters[name] = func + logger.debug(f"已注册自定义 Jinja2 过滤器: '{name}'") + return func + + return decorator + + def global_function(self, name: str) -> Callable: + """ + 装饰器:注册一个自定义 Jinja2 全局函数。 + + 参数: + name: 函数在模板中的调用名称。为避免冲突,强烈建议使用 + '插件名_函数名' 的格式。 + """ + + def decorator(func: Callable) -> Callable: + if name in self._custom_globals: + logger.warning(f"Jinja2 全局函数 '{name}' 已被注册,将被覆盖。") + self._custom_globals[name] = func + logger.debug(f"已注册自定义 Jinja2 全局函数: '{name}'") + return func + + return decorator + + async def _load_theme(self, theme_name: str): + """加载指定主题的配置和样式。""" + theme_dir = THEME_PATH / theme_name + if not theme_dir.is_dir(): + logger.error(f"主题 '{theme_name}' 不存在,将回退到默认主题。") + if theme_name == "default": + return + theme_name = "default" + theme_dir = THEME_PATH / "default" + + palette_path = theme_dir / "palette.json" + default_palette_path = THEMES_PATH / "default" / "palette.json" + + palette = {} + if palette_path.exists(): + try: + palette = json.loads(palette_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + logger.warning(f"主题 '{theme_name}' 的 palette.json 文件解析失败。") + + if not palette and default_palette_path.exists(): + logger.debug( + f"主题 '{theme_name}' 未提供有效的 palette.json," + "回退到默认主题的调色板。" + ) + try: + palette = json.loads(default_palette_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + logger.error("默认主题的 palette.json 文件解析失败,调色板将为空。") + palette = {} + elif not palette: + logger.error("当前主题和默认主题均未找到有效的 palette.json。") + + self._current_theme_data = Theme( + name=theme_name, + palette=palette, + style_css="", + assets_dir=theme_dir / "assets", + default_assets_dir=THEMES_PATH / "default" / "assets", + ) + self._jinja_environments.clear() + logger.info(f"渲染服务已加载主题: {theme_name}") + + async def reload_theme(self) -> str: + """ + 重新加载当前主题的配置和样式,并清除缓存的Jinja环境。 + """ + async with self._init_lock: + current_theme_name = Config.get_config("UI", "THEME", "default") + await self._load_theme(current_theme_name) + logger.info(f"主题 '{current_theme_name}' 已成功重载。") + return current_theme_name + + def _get_or_create_jinja_env(self, theme: Theme) -> Environment: + """为指定主题获取或创建一个缓存的 Jinja2 环境。""" + if theme.name in self._jinja_environments: + return self._jinja_environments[theme.name] + + logger.debug(f"为主题 '{theme.name}' 创建新的 Jinja2 环境...") + + prefix_loader = PrefixLoader( + { + namespace: FileSystemLoader(str(path.absolute())) + for namespace, path in self._plugin_template_paths.items() + } + ) + + current_theme_templates_dir = THEMES_PATH / theme.name / "templates" + default_theme_templates_dir = THEMES_PATH / "default" / "templates" + theme_loader = FileSystemLoader( + [ + str(current_theme_templates_dir.absolute()), + str(default_theme_templates_dir.absolute()), + ] + ) + + final_loader = ChoiceLoader([prefix_loader, theme_loader]) + + env = Environment( + loader=final_loader, + enable_async=True, + autoescape=True, + ) + + def markdown_filter(text: str) -> str: + """一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。""" + if not isinstance(text, str): + return "" + return markdown.markdown( + text, + extensions=[ + "pymdownx.tasklist", + "tables", + "fenced_code", + "codehilite", + "mdx_math", + "pymdownx.tilde", + ], + extension_configs={"mdx_math": {"enable_dollar_delimiter": True}}, + ) + + env.filters["md"] = markdown_filter + + if self._custom_filters: + env.filters.update(self._custom_filters) + logger.debug( + f"向 Jinja2 环境注入了 {len(self._custom_filters)} 个自定义过滤器。" + ) + if self._custom_globals: + env.globals.update(self._custom_globals) + logger.debug( + f"向 Jinja2 环境注入了 {len(self._custom_globals)} 个自定义全局函数。" + ) + + self._jinja_environments[theme.name] = env + return env + + async def initialize(self): + """扫描并加载所有模板清单。""" + if self._initialized: + return + async with self._init_lock: + if self._initialized: + return + + logger.info("开始扫描渲染模板...") + base_template_path = THEMES_PATH / "default" / "templates" + base_template_path.mkdir(exist_ok=True, parents=True) + + for manifest_path in base_template_path.glob("**/manifest.json"): + template_dir = manifest_path.parent + try: + manifest = TemplateManifest.parse_file(manifest_path) + + template_name = template_dir.relative_to( + base_template_path + ).as_posix() + + self._templates[template_name] = manifest + self._template_paths[template_name] = template_dir + logger.debug( + f"发现并加载基础模板 '{template_name}' " + f"(引擎: {manifest.engine})" + ) + except ValidationError as e: + logger.error(f"解析模板清单 '{manifest_path}' 失败: {e}") + + for namespace, plugin_template_path in self._plugin_template_paths.items(): + for manifest_path in plugin_template_path.glob("**/manifest.json"): + template_dir = manifest_path.parent + try: + manifest = TemplateManifest.parse_file(manifest_path) + + relative_path = template_dir.relative_to( + plugin_template_path + ).as_posix() + template_name_with_ns = f"{namespace}:{relative_path}" + + self._plugin_manifests[template_name_with_ns] = manifest + logger.debug( + f"发现并加载插件模板 '{template_name_with_ns}' " + f"(引擎: {manifest.engine})" + ) + except ValidationError as e: + logger.error(f"解析插件模板清单 '{manifest_path}' 失败: {e}") + + current_theme_name = Config.get_config("UI", "THEME", "default") + await self._load_theme(current_theme_name) + + self._initialized = True + logger.info( + f"渲染模板扫描完成,共加载 {len(self._templates)} 个基础模板和 " + f"{len(self._plugin_manifests)} 个插件模板。" + ) + + def _yield_theme_paths(self, relative_path: Path) -> Generator[Path, None, None]: + """ + 按优先级生成一个资源的完整路径(当前主题 -> 默认主题)。 + """ + if not self._current_theme_data: + return + + current_theme_path = THEMES_PATH / self._current_theme_data.name / relative_path + yield current_theme_path + + if self._current_theme_data.name != "default": + default_theme_path = THEMES_PATH / "default" / relative_path + yield default_theme_path + + def _resolve_markdown_style_path(self, style_name: str) -> Path | None: + """ + 按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。 + """ + if style_name in self._markdown_styles: + logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'") + return self._markdown_styles[style_name] + + conventional_relative_paths = [ + Path("templates") + / "components" + / "cards" + / "markdown_image" + / "styles" + / f"{style_name}.css", + Path("assets") / "css" / "markdown" / f"{style_name}.css", + ] + + for relative_path in conventional_relative_paths: + for potential_path in self._yield_theme_paths(relative_path): + if potential_path.exists(): + logger.debug(f"在约定路径找到 Markdown 样式: {potential_path}") + return potential_path + + logger.warning(f"样式 '{style_name}' 在注册表和约定路径中均未找到。") + return None + + def _resolve_style_path(self, template_name: str, style_name: str) -> Path | None: + """ + [重构后] 实现 当前主题 -> 默认主题 的回退查找逻辑 + """ + relative_style_path = ( + Path("templates") / template_name / "styles" / f"{style_name}.css" + ) + + for potential_path in self._yield_theme_paths(relative_style_path): + if potential_path.exists(): + logger.debug(f"找到样式 '{style_name}': {potential_path}") + return potential_path + + logger.warning(f"样式 '{style_name}' 在当前主题和默认主题中均未找到。") + return None + + async def render( + self, + template_name: str, + data: dict | BaseModel | None = None, + use_cache: bool = False, + style_name: str | None = None, + **render_options_override, + ) -> bytes: + """ + 渲染指定的模板,并支持透明缓存。 + """ + await self.initialize() + + try: + extra_css_paths = [] + custom_markdown_css_path = None + manifest: TemplateManifest | None = self._templates.get( + template_name + ) or self._plugin_manifests.get(template_name) + + if style_name: + if manifest and manifest.engine == "markdown": + custom_markdown_css_path = self._resolve_markdown_style_path( + style_name + ) + else: + resolved_path = self._resolve_style_path(template_name, style_name) + if resolved_path: + extra_css_paths.append(resolved_path) + + cache_path = None + if Config.get_config("UI", "CACHE") and use_cache: + try: + if isinstance(data, BaseModel): + data_str = f"{data.__class__.__name__}:{data!s}" + else: + data_str = json.dumps(data or {}, sort_keys=True) + cache_key_str = f"{template_name}:{data_str}" + cache_filename = ( + f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png" + ) + cache_path = UI_CACHE_PATH / cache_filename + + if cache_path.exists(): + logger.debug(f"UI缓存命中: {cache_path}") + async with aiofiles.open(cache_path, "rb") as f: + return await f.read() + logger.debug(f"UI缓存未命中: {cache_key_str[:100]}...") + except Exception as e: + logger.warning(f"UI缓存读取失败: {e}", e=e) + cache_path = None + + if not self._current_theme_data: + raise RuntimeError("主题未被正确加载,无法进行渲染。") + + manifest: TemplateManifest | None = None + final_template_dir: Path | None = None + relative_template_name: str = "" + is_plugin_template = ":" in template_name + + if is_plugin_template: + namespace, path_part = template_name.split(":", 1) + manifest = self._plugin_manifests.get(template_name) + if namespace in self._plugin_template_paths: + plugin_base_path = self._plugin_template_paths[namespace] + final_template_dir = plugin_base_path / Path(path_part).parent + + relative_template_name = template_name + if manifest: + logger.debug(f"使用插件模板: '{template_name}'") + + else: + theme_template_dir = ( + THEMES_PATH + / self._current_theme_data.name + / "templates" + / template_name + ) + default_template_dir = ( + THEMES_PATH / "default" / "templates" / template_name + ) + + if ( + theme_template_dir.is_dir() + and (theme_template_dir / "manifest.json").is_file() + ): + final_template_dir = theme_template_dir + logger.debug( + f"使用主题 '{self._current_theme_data.name}' " + f"覆盖的模板: '{template_name}'" + ) + elif ( + default_template_dir.is_dir() + and (default_template_dir / "manifest.json").is_file() + ): + final_template_dir = default_template_dir + logger.debug(f"使用基础(default)模板: '{template_name}'") + + if final_template_dir: + try: + manifest = TemplateManifest.parse_file( + final_template_dir / "manifest.json" + ) + relative_template_name = ( + Path(template_name) / manifest.entrypoint + ).as_posix() + except (ValidationError, FileNotFoundError) as e: + logger.error(f"无法加载模板 '{template_name}' 的清单文件: {e}") + manifest = None + + if not manifest or not final_template_dir: + raise ValueError(f"模板 '{template_name}' 未找到或清单文件加载失败。") + + engine_name = manifest.engine + engine = self._engines.get(engine_name) + if not engine: + raise ValueError(f"未找到名为 '{engine_name}' 的渲染引擎。") + jinja_environment = self._get_or_create_jinja_env(self._current_theme_data) + + final_render_options = manifest.render_options.copy() + final_render_options.update(render_options_override) + + image_bytes = await engine.render( + template_name=relative_template_name, + data=data, + theme=self._current_theme_data, + jinja_env=jinja_environment, + extra_css_paths=extra_css_paths, + custom_css_path=custom_markdown_css_path, + **final_render_options, + ) + + if Config.get_config("UI", "CACHE") and use_cache and cache_path: + try: + async with aiofiles.open(cache_path, "wb") as f: + await f.write(image_bytes) + logger.debug(f"UI缓存写入成功: {cache_path}") + except Exception as e: + logger.warning(f"UI缓存写入失败: {e}", e=e) + + return image_bytes + + except Exception as e: + logger.error( + f"渲染模板 '{template_name}' 时发生错误", "RendererService", e=e + ) + raise RenderingError(f"渲染模板 '{template_name}' 失败") from e diff --git a/zhenxun/ui/__init__.py b/zhenxun/ui/__init__.py new file mode 100644 index 00000000..f3035a34 --- /dev/null +++ b/zhenxun/ui/__init__.py @@ -0,0 +1,40 @@ +from . import builders, models +from .builders import ( + InfoCardBuilder, + LayoutBuilder, + MarkdownBuilder, + NotebookBuilder, + PluginHelpPageBuilder, + PluginMenuBuilder, + TableBuilder, +) +from .models import ( + HelpCategory, + HelpItem, + InfoCardData, + PluginHelpPageData, + PluginMenuCategory, + PluginMenuData, + PluginMenuItem, + RenderableComponent, +) + +__all__ = [ + "HelpCategory", + "HelpItem", + "InfoCardBuilder", + "InfoCardData", + "LayoutBuilder", + "MarkdownBuilder", + "NotebookBuilder", + "PluginHelpPageBuilder", + "PluginHelpPageData", + "PluginMenuBuilder", + "PluginMenuCategory", + "PluginMenuData", + "PluginMenuItem", + "RenderableComponent", + "TableBuilder", + "builders", + "models", +] diff --git a/zhenxun/ui/builders/__init__.py b/zhenxun/ui/builders/__init__.py new file mode 100644 index 00000000..5611282a --- /dev/null +++ b/zhenxun/ui/builders/__init__.py @@ -0,0 +1,19 @@ +from . import widgets +from .core.layout import LayoutBuilder +from .core.markdown import MarkdownBuilder +from .core.notebook import NotebookBuilder +from .core.table import TableBuilder +from .presets.help_page import PluginHelpPageBuilder +from .presets.info_card import InfoCardBuilder +from .presets.plugin_menu import PluginMenuBuilder + +__all__ = [ + "InfoCardBuilder", + "LayoutBuilder", + "MarkdownBuilder", + "NotebookBuilder", + "PluginHelpPageBuilder", + "PluginMenuBuilder", + "TableBuilder", + "widgets", +] diff --git a/zhenxun/ui/builders/base.py b/zhenxun/ui/builders/base.py new file mode 100644 index 00000000..229a973d --- /dev/null +++ b/zhenxun/ui/builders/base.py @@ -0,0 +1,41 @@ +from typing import Generic, TypeVar +from typing_extensions import Self + +from pydantic import BaseModel + +from zhenxun.services import renderer_service + +T_DataModel = TypeVar("T_DataModel", bound=BaseModel) + + +class BaseBuilder(Generic[T_DataModel]): + """所有UI构建器的基类,提供通用的样式化和构建逻辑。""" + + 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 + + def with_style(self, style_name: str) -> Self: + """ + 为组件应用一个特定的样式。 + """ + self._style_name = style_name + return self + + async def build(self, use_cache: bool = False, **render_options) -> bytes: + """ + 通用的构建方法,将数据渲染为图片。 + """ + if self._style_name and hasattr(self._data, "style_name"): + setattr(self._data, "style_name", self._style_name) + + data_to_render = self._data + + return await renderer_service.render( + template_name=self._template_name, + data=data_to_render, + use_cache=use_cache, + style_name=self._style_name, + **render_options, + ) diff --git a/zhenxun/ui/builders/charts.py b/zhenxun/ui/builders/charts.py new file mode 100644 index 00000000..aca84133 --- /dev/null +++ b/zhenxun/ui/builders/charts.py @@ -0,0 +1,88 @@ +from typing import Any, Generic, Literal, TypeVar +from typing_extensions import Self + +from ..models.charts import ( + BarChartData, + BaseChartData, + LineChartData, + LineChartSeries, + PieChartData, + PieChartDataItem, +) +from .base import BaseBuilder + +T_ChartData = TypeVar("T_ChartData", bound=BaseChartData) + + +class BaseChartBuilder(BaseBuilder[T_ChartData], Generic[T_ChartData]): + """所有图表构建器的基类""" + + def set_title(self, title: str) -> Self: + self._data.title = title + return self + + +class BarChartBuilder(BaseChartBuilder[BarChartData]): + """链式构建柱状图的辅助类 (支持横向和竖向)""" + + def __init__( + self, title: str, direction: Literal["horizontal", "vertical"] = "horizontal" + ): + data_model = BarChartData( + title=title, direction=direction, category_data=[], data=[] + ) + super().__init__(data_model, template_name="components/charts/bar_chart") + + def add_data(self, category: str, value: float) -> Self: + """添加一个数据点""" + self._data.category_data.append(category) + self._data.data.append(value) + return self + + def add_data_items( + self, items: list[tuple[str, int | float]] | list[dict[str, Any]] + ) -> Self: + for item in items: + if isinstance(item, tuple): + self.add_data(item[0], item[1]) + elif isinstance(item, dict): + self.add_data(item.get("category", ""), item.get("value", 0)) + return self + + def set_background_image(self, background_image: str) -> Self: + """设置背景图片 (仅横向柱状图模板支持)""" + self._data.background_image = background_image + return self + + +class PieChartBuilder(BaseChartBuilder[PieChartData]): + """链式构建饼图的辅助类""" + + def __init__(self, title: str): + data_model = PieChartData(title=title, data=[]) + super().__init__(data_model, template_name="components/charts/pie_chart") + + def add_slice(self, name: str, value: float) -> Self: + """添加一个饼图扇区""" + self._data.data.append(PieChartDataItem(name=name, value=value)) + return self + + +class LineChartBuilder(BaseChartBuilder[LineChartData]): + """链式构建折线图的辅助类""" + + def __init__(self, title: str): + data_model = LineChartData(title=title, category_data=[], series=[]) + super().__init__(data_model, template_name="components/charts/line_chart") + + def set_categories(self, categories: list[str]) -> Self: + """设置X轴的分类标签""" + self._data.category_data = categories + return self + + def add_series( + self, name: str, data: list[int | float], smooth: bool = False + ) -> Self: + """添加一条折线""" + self._data.series.append(LineChartSeries(name=name, data=data, smooth=smooth)) + return self diff --git a/zhenxun/ui/builders/core/__init__.py b/zhenxun/ui/builders/core/__init__.py new file mode 100644 index 00000000..50052b2a --- /dev/null +++ b/zhenxun/ui/builders/core/__init__.py @@ -0,0 +1,16 @@ +""" +核心构建器模块 +包含基础的UI构建器类 +""" + +from .layout import LayoutBuilder +from .markdown import MarkdownBuilder +from .notebook import NotebookBuilder +from .table import TableBuilder + +__all__ = [ + "LayoutBuilder", + "MarkdownBuilder", + "NotebookBuilder", + "TableBuilder", +] diff --git a/zhenxun/ui/builders/core/layout.py b/zhenxun/ui/builders/core/layout.py new file mode 100644 index 00000000..307af928 --- /dev/null +++ b/zhenxun/ui/builders/core/layout.py @@ -0,0 +1,117 @@ +import base64 +from typing import Any +from typing_extensions import Self + +from ...models.core.layout import LayoutData, LayoutItem +from ..base import BaseBuilder + +__all__ = ["LayoutBuilder"] + + +class LayoutBuilder(BaseBuilder[LayoutData]): + """ + 一个用于将多个图片(bytes)组合成单张图片的链式构建器。 + 采用混合模式,提供便捷的工厂方法和灵活的自定义模板能力。 + """ + + def __init__(self): + super().__init__(LayoutData(), template_name="") + self._items: list[LayoutItem] = [] + self._options: dict[str, Any] = {} + self._preset_template_name: str | None = None + + @classmethod + def column(cls, **options: Any) -> Self: + """ + 工厂方法:创建一个垂直列布局的构建器。 + :param options: 传递给模板的选项,如 gap, padding, align_items 等。 + """ + builder = cls() + builder._preset_template_name = "layouts/column" + builder._options.update(options) + return builder + + @classmethod + def grid(cls, **options: Any) -> Self: + """ + 工厂方法:创建一个网格布局的构建器。 + :param options: 传递给模板的选项,如 columns, gap, padding 等。 + """ + builder = cls() + builder._preset_template_name = "layouts/grid" + builder._options.update(options) + return builder + + @classmethod + def vstack(cls, images: list[bytes], **options: Any) -> Self: + """ + 工厂方法:创建一个垂直堆叠布局的构建器,并直接添加图片。 + + 参数: + images: 要垂直堆叠的图片字节流列表。 + options: 传递给模板的选项,如 gap, padding, align_items 等。 + """ + builder = cls.column(**options) + for image_bytes in images: + builder.add_item(image_bytes) + return builder + + @classmethod + def hstack(cls, images: list[bytes], **options: Any) -> Self: + """ + 工厂方法:创建一个水平堆叠布局的构建器,并直接添加图片。 + + 参数: + images: 要水平堆叠的图片字节流列表。 + options: 传递给模板的选项,如 gap, padding, align_items 等。 + """ + builder = cls() + builder._preset_template_name = "layouts/row" + builder._options.update(options) + for image_bytes in images: + builder.add_item(image_bytes) + return builder + + def add_item( + self, image_bytes: bytes, metadata: dict[str, Any] | None = None + ) -> Self: + """ + 向布局中添加一个图片项目。 + :param image_bytes: 图片的原始字节数据。 + :param metadata: (可选) 与此项目关联的元数据,可用于模板。 + """ + b64_string = base64.b64encode(image_bytes).decode("utf-8") + src = f"data:image/png;base64,{b64_string}" + self._items.append(LayoutItem(src=src, metadata=metadata)) + return self + + def add_option(self, key: str, value: Any) -> Self: + """ + 为布局添加一个自定义选项,该选项会传递给模板。 + """ + self._options[key] = value + return self + + async def build( + self, use_cache: bool = False, template: str | None = None, **render_options + ) -> bytes: + """ + 构建最终的布局图片。 + :param use_cache: 是否使用缓存。 + :param template: (可选) 强制使用指定的模板,覆盖工厂方法的预设。 + 这是实现自定义布局的关键。 + :param render_options: 传递给渲染引擎的额外选项。 + """ + final_template_name = template or self._preset_template_name + + if not final_template_name: + raise ValueError( + "必须通过工厂方法 (如 LayoutBuilder.column()) 或在 build() " + "方法中提供一个模板名称。" + ) + + self._data.items = self._items + self._data.options = self._options + self._template_name = final_template_name + + return await super().build(use_cache=use_cache, **render_options) diff --git a/zhenxun/ui/builders/core/markdown.py b/zhenxun/ui/builders/core/markdown.py new file mode 100644 index 00000000..19729780 --- /dev/null +++ b/zhenxun/ui/builders/core/markdown.py @@ -0,0 +1,149 @@ +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Any + +from ...models.core.markdown import ( + CodeElement, + HeadingElement, + ImageElement, + ListElement, + ListItemElement, + MarkdownData, + MarkdownElement, + QuoteElement, + RawHtmlElement, + TableElement, + TextElement, +) +from ..base import BaseBuilder + +__all__ = ["MarkdownBuilder"] + + +class MarkdownBuilder(BaseBuilder[MarkdownData]): + """链式构建Markdown图片的辅助类,支持上下文管理和组合。""" + + def __init__(self): + data_model = MarkdownData(markdown="", 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_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 + + async def build(self, use_cache: bool = False, **render_options) -> bytes: + """构建Markdown图片""" + final_markdown = "\n\n".join(part.to_markdown() for part in self._parts).strip() + self._data.markdown = final_markdown + self._data.width = self._width + self._data.css_path = self._css_path + return await super().build(use_cache=use_cache, **render_options) diff --git a/zhenxun/ui/builders/core/notebook.py b/zhenxun/ui/builders/core/notebook.py new file mode 100644 index 00000000..87573a13 --- /dev/null +++ b/zhenxun/ui/builders/core/notebook.py @@ -0,0 +1,107 @@ +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) -> "NotebookBuilder": + """向 Notebook 中添加一个可渲染的自定义组件。""" + self._elements.append( + NotebookElement(type="component", component_data=component) + ) + 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 + + async def build( + self, use_cache: bool = False, frameless: bool = False, **render_options + ) -> bytes: + """构建Notebook图片""" + self._data.elements = self._elements + return await super().build( + use_cache=use_cache, frameless=frameless, **render_options + ) diff --git a/zhenxun/ui/builders/core/table.py b/zhenxun/ui/builders/core/table.py new file mode 100644 index 00000000..a0016635 --- /dev/null +++ b/zhenxun/ui/builders/core/table.py @@ -0,0 +1,27 @@ +from ...models.core.table import TableCell, TableData +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 set_headers(self, headers: list[str]) -> "TableBuilder": + """设置表头""" + self._data.headers = headers + return self + + def add_row(self, row: list[TableCell]) -> "TableBuilder": + """添加单行数据""" + self._data.rows.append(row) + return self + + def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder": + """批量添加多行数据""" + self._data.rows.extend(rows) + return self diff --git a/zhenxun/ui/builders/presets/__init__.py b/zhenxun/ui/builders/presets/__init__.py new file mode 100644 index 00000000..cb6b9ef6 --- /dev/null +++ b/zhenxun/ui/builders/presets/__init__.py @@ -0,0 +1,14 @@ +""" +预设构建器模块 +包含预定义的UI组件构建器 +""" + +from .help_page import PluginHelpPageBuilder +from .info_card import InfoCardBuilder +from .plugin_menu import PluginMenuBuilder + +__all__ = [ + "InfoCardBuilder", + "PluginHelpPageBuilder", + "PluginMenuBuilder", +] diff --git a/zhenxun/ui/builders/presets/help_page.py b/zhenxun/ui/builders/presets/help_page.py new file mode 100644 index 00000000..51e4f5b8 --- /dev/null +++ b/zhenxun/ui/builders/presets/help_page.py @@ -0,0 +1,27 @@ +from ...models.presets.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/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/info_card.py b/zhenxun/ui/builders/presets/info_card.py new file mode 100644 index 00000000..8567448b --- /dev/null +++ b/zhenxun/ui/builders/presets/info_card.py @@ -0,0 +1,46 @@ +from typing import Any + +from ...models.presets.card import ( + InfoCardData, + InfoCardMetadataItem, + InfoCardSection, +) +from ..base import BaseBuilder + +__all__ = ["InfoCardBuilder"] + + +class InfoCardBuilder(BaseBuilder[InfoCardData]): + def __init__(self, title: str): + self._data = InfoCardData(title=title) + + super().__init__(self._data, template_name="components/presets/info_card") + + def add_metadata(self, label: str, value: str | int) -> "InfoCardBuilder": + self._data.metadata.append(InfoCardMetadataItem(label=label, value=value)) + return self + + def add_metadata_items( + self, items: list[tuple[str, Any]] | list[dict[str, Any]] + ) -> "InfoCardBuilder": + for item in items: + if isinstance(item, tuple): + self.add_metadata(item[0], item[1]) + elif isinstance(item, dict): + self.add_metadata(item.get("label", ""), item.get("value", "")) + return self + + def add_section(self, title: str, content: str | list[str]) -> "InfoCardBuilder": + content_list = [content] if isinstance(content, str) else content + self._data.sections.append(InfoCardSection(title=title, content=content_list)) + return self + + def add_sections( + self, sections: list[tuple[str, str | list[str]]] | list[dict[str, Any]] + ) -> "InfoCardBuilder": + for section in sections: + if isinstance(section, tuple): + self.add_section(section[0], section[1]) + elif isinstance(section, dict): + self.add_section(section.get("title", ""), section.get("content", [])) + return self diff --git a/zhenxun/ui/builders/presets/plugin_menu.py b/zhenxun/ui/builders/presets/plugin_menu.py new file mode 100644 index 00000000..c8183aff --- /dev/null +++ b/zhenxun/ui/builders/presets/plugin_menu.py @@ -0,0 +1,36 @@ +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/builders/widgets/__init__.py b/zhenxun/ui/builders/widgets/__init__.py new file mode 100644 index 00000000..9b915dc1 --- /dev/null +++ b/zhenxun/ui/builders/widgets/__init__.py @@ -0,0 +1,14 @@ +""" +小组件构建器模块 +包含各种UI小组件的构建器 +""" + +from .badge import BadgeBuilder +from .progress_bar import ProgressBarBuilder +from .user_info_block import UserInfoBlockBuilder + +__all__ = [ + "BadgeBuilder", + "ProgressBarBuilder", + "UserInfoBlockBuilder", +] diff --git a/zhenxun/ui/builders/widgets/badge.py b/zhenxun/ui/builders/widgets/badge.py new file mode 100644 index 00000000..18366ae1 --- /dev/null +++ b/zhenxun/ui/builders/widgets/badge.py @@ -0,0 +1,25 @@ +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/widgets/progress_bar.py b/zhenxun/ui/builders/widgets/progress_bar.py new file mode 100644 index 00000000..5d772c32 --- /dev/null +++ b/zhenxun/ui/builders/widgets/progress_bar.py @@ -0,0 +1,42 @@ +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/widgets/user_info_block.py b/zhenxun/ui/builders/widgets/user_info_block.py new file mode 100644 index 00000000..2d323522 --- /dev/null +++ b/zhenxun/ui/builders/widgets/user_info_block.py @@ -0,0 +1,33 @@ +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/models/__init__.py b/zhenxun/ui/models/__init__.py new file mode 100644 index 00000000..6d539eb5 --- /dev/null +++ b/zhenxun/ui/models/__init__.py @@ -0,0 +1,84 @@ +from .charts import ( + BarChartData, + BaseChartData, + LineChartData, + LineChartSeries, + PieChartData, + PieChartDataItem, +) +from .components.badge import Badge +from .components.divider import Divider, Rectangle +from .components.progress_bar import ProgressBar +from .components.user_info_block import UserInfoBlock +from .core.base import RenderableComponent +from .core.layout import LayoutData, LayoutItem +from .core.markdown import ( + CodeElement, + HeadingElement, + ImageElement, + ListElement, + ListItemElement, + MarkdownData, + MarkdownElement, + QuoteElement, + RawHtmlElement, + TableElement, + TextElement, +) +from .core.notebook import NotebookData, NotebookElement +from .core.table import ( + BaseCell, + ImageCell, + StatusBadgeCell, + TableCell, + TableData, + TextCell, +) +from .presets.card import InfoCardData, InfoCardMetadataItem, InfoCardSection +from .presets.help_page import HelpCategory, HelpItem, PluginHelpPageData +from .presets.plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem + +__all__ = [ + "Badge", + "BarChartData", + "BaseCell", + "BaseChartData", + "CodeElement", + "Divider", + "HeadingElement", + "HelpCategory", + "HelpItem", + "ImageCell", + "ImageElement", + "InfoCardData", + "InfoCardMetadataItem", + "InfoCardSection", + "LayoutData", + "LayoutItem", + "LineChartData", + "LineChartSeries", + "ListElement", + "ListItemElement", + "MarkdownData", + "MarkdownElement", + "NotebookData", + "NotebookElement", + "PieChartData", + "PieChartDataItem", + "PluginHelpPageData", + "PluginMenuCategory", + "PluginMenuData", + "PluginMenuItem", + "ProgressBar", + "QuoteElement", + "RawHtmlElement", + "Rectangle", + "RenderableComponent", + "StatusBadgeCell", + "TableCell", + "TableData", + "TableElement", + "TextCell", + "TextElement", + "UserInfoBlock", +] diff --git a/zhenxun/ui/models/charts.py b/zhenxun/ui/models/charts.py new file mode 100644 index 00000000..cdccfec5 --- /dev/null +++ b/zhenxun/ui/models/charts.py @@ -0,0 +1,43 @@ +from typing import Literal + +from pydantic import BaseModel + + +class BaseChartData(BaseModel): + """所有图表数据模型的基类""" + + style_name: str | None = None + title: str + + +class BarChartData(BaseChartData): + """柱状图(支持横向和竖向)的数据模型""" + + category_data: list[str] + data: list[int | float] + direction: Literal["horizontal", "vertical"] = "horizontal" + background_image: str | None = None + + +class PieChartDataItem(BaseModel): + name: str + value: int | float + + +class PieChartData(BaseChartData): + """饼图的数据模型""" + + data: list[PieChartDataItem] + + +class LineChartSeries(BaseModel): + name: str + data: list[int | float] + smooth: bool = False + + +class LineChartData(BaseChartData): + """折线图的数据模型""" + + category_data: list[str] + series: list[LineChartSeries] diff --git a/zhenxun/ui/models/components/__init__.py b/zhenxun/ui/models/components/__init__.py new file mode 100644 index 00000000..89adcac8 --- /dev/null +++ b/zhenxun/ui/models/components/__init__.py @@ -0,0 +1,17 @@ +""" +组件模型模块 +包含各种UI组件的数据模型 +""" + +from .badge import Badge +from .divider import Divider, Rectangle +from .progress_bar import ProgressBar +from .user_info_block import UserInfoBlock + +__all__ = [ + "Badge", + "Divider", + "ProgressBar", + "Rectangle", + "UserInfoBlock", +] diff --git a/zhenxun/ui/models/components/badge.py b/zhenxun/ui/models/components/badge.py new file mode 100644 index 00000000..8c4b9644 --- /dev/null +++ b/zhenxun/ui/models/components/badge.py @@ -0,0 +1,22 @@ +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/main.html" diff --git a/zhenxun/ui/models/components/divider.py b/zhenxun/ui/models/components/divider.py new file mode 100644 index 00000000..67e76656 --- /dev/null +++ b/zhenxun/ui/models/components/divider.py @@ -0,0 +1,35 @@ +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属性,控制分割线上下的间距") + 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/main.html" + + +class Rectangle(RenderableComponent): + """一个矩形背景块组件。""" + + component_type: Literal["rectangle"] = "rectangle" + height: str = Field("50px", description="矩形的高度 (CSS value)") + background_color: str = Field("#fdf1f5", description="背景颜色") + border: str = Field("1px solid #fce4ec", description="CSS border属性") + border_radius: str = Field("8px", description="CSS border-radius属性") + + @property + def template_name(self) -> str: + return "components/widgets/rectangle/main.html" diff --git a/zhenxun/ui/models/components/progress_bar.py b/zhenxun/ui/models/components/progress_bar.py new file mode 100644 index 00000000..cfa84b3d --- /dev/null +++ b/zhenxun/ui/models/components/progress_bar.py @@ -0,0 +1,24 @@ +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)") + 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/main.html" diff --git a/zhenxun/ui/models/components/user_info_block.py b/zhenxun/ui/models/components/user_info_block.py new file mode 100644 index 00000000..2867896d --- /dev/null +++ b/zhenxun/ui/models/components/user_info_block.py @@ -0,0 +1,23 @@ +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") + name: str = Field(..., description="用户的名称") + subtitle: str | None = Field( + default=None, description="显示在名称下方的副标题 (如UID或角色)" + ) + tags: list[str] = Field(default_factory=list, description="附加的标签列表") + + @property + def template_name(self) -> str: + return "components/widgets/user_info_block/main.html" diff --git a/zhenxun/ui/models/core/__init__.py b/zhenxun/ui/models/core/__init__.py new file mode 100644 index 00000000..b95be74a --- /dev/null +++ b/zhenxun/ui/models/core/__init__.py @@ -0,0 +1,47 @@ +""" +核心模型模块 +包含基础的数据模型类 +""" + +from .base import RenderableComponent +from .layout import LayoutData, LayoutItem +from .markdown import ( + CodeElement, + HeadingElement, + ImageElement, + ListElement, + ListItemElement, + MarkdownData, + MarkdownElement, + QuoteElement, + RawHtmlElement, + TableElement, + TextElement, +) +from .notebook import NotebookData, NotebookElement +from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell + +__all__ = [ + "BaseCell", + "CodeElement", + "HeadingElement", + "ImageCell", + "ImageElement", + "LayoutData", + "LayoutItem", + "ListElement", + "ListItemElement", + "MarkdownData", + "MarkdownElement", + "NotebookData", + "NotebookElement", + "QuoteElement", + "RawHtmlElement", + "RenderableComponent", + "StatusBadgeCell", + "TableCell", + "TableData", + "TableElement", + "TextCell", + "TextElement", +] diff --git a/zhenxun/ui/models/core/base.py b/zhenxun/ui/models/core/base.py new file mode 100644 index 00000000..cdb8c102 --- /dev/null +++ b/zhenxun/ui/models/core/base.py @@ -0,0 +1,20 @@ +""" +核心基础模型定义 +用于存放 RenderableComponent 基类 +""" + +from abc import ABC, abstractmethod + +from pydantic import BaseModel + +__all__ = ["RenderableComponent"] + + +class RenderableComponent(BaseModel, ABC): + """所有可渲染UI组件的抽象基类。""" + + @property + @abstractmethod + def template_name(self) -> str: + """返回用于渲染此组件的Jinja2模板的路径。""" + pass diff --git a/zhenxun/ui/models/core/layout.py b/zhenxun/ui/models/core/layout.py new file mode 100644 index 00000000..f96bcb38 --- /dev/null +++ b/zhenxun/ui/models/core/layout.py @@ -0,0 +1,24 @@ +from typing import Any + +from pydantic import BaseModel, Field + +__all__ = ["LayoutData", "LayoutItem"] + + +class LayoutItem(BaseModel): + """布局中的单个项目,通常是一张图片""" + + src: str = Field(..., description="图片的Base64数据URI") + metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据") + + +class LayoutData(BaseModel): + """布局构建器的数据模型""" + + style_name: str | None = None + items: list[LayoutItem] = Field( + default_factory=list, description="要布局的项目列表" + ) + options: dict[str, Any] = Field( + default_factory=dict, description="传递给模板的布局选项" + ) diff --git a/zhenxun/ui/models/core/markdown.py b/zhenxun/ui/models/core/markdown.py new file mode 100644 index 00000000..a615d30b --- /dev/null +++ b/zhenxun/ui/models/core/markdown.py @@ -0,0 +1,124 @@ +from abc import ABC, abstractmethod +from typing import Literal + +from pydantic import BaseModel, Field + +__all__ = [ + "CodeElement", + "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): + text: str + + def to_markdown(self) -> str: + return self.text + + +class HeadingElement(MarkdownElement): + text: str + level: int = Field(..., ge=1, le=6) + + def to_markdown(self) -> str: + return f"{'#' * self.level} {self.text}" + + +class ImageElement(MarkdownElement): + src: str + alt: str = "image" + + def to_markdown(self) -> str: + return f"![{self.alt}]({self.src})" + + +class CodeElement(MarkdownElement): + code: str + language: str = "" + + def to_markdown(self) -> str: + return f"```{self.language}\n{self.code}\n```" + + +class RawHtmlElement(MarkdownElement): + html: str + + def to_markdown(self) -> str: + return self.html + + +class TableElement(MarkdownElement): + 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): + 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): + 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 MarkdownData(BaseModel): + """Markdown转图片的数据模型""" + + style_name: str | None = None + markdown: str + width: int = 800 + css_path: str | None = None diff --git a/zhenxun/ui/models/core/notebook.py b/zhenxun/ui/models/core/notebook.py new file mode 100644 index 00000000..9b0024cb --- /dev/null +++ b/zhenxun/ui/models/core/notebook.py @@ -0,0 +1,38 @@ +from typing import Literal + +from pydantic import BaseModel + +from .base import 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 + 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_data: RenderableComponent | None = None + + +class NotebookData(BaseModel): + """Notebook转图片的数据模型""" + + style_name: str | None = None + elements: list[NotebookElement] diff --git a/zhenxun/ui/models/core/table.py b/zhenxun/ui/models/core/table.py new file mode 100644 index 00000000..fd811020 --- /dev/null +++ b/zhenxun/ui/models/core/table.py @@ -0,0 +1,59 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +__all__ = [ + "BaseCell", + "ImageCell", + "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" + + +TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None + + +class TableData(BaseModel): + """通用表格的数据模型""" + + 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="数据行列表") diff --git a/zhenxun/ui/models/presets/__init__.py b/zhenxun/ui/models/presets/__init__.py new file mode 100644 index 00000000..de259e50 --- /dev/null +++ b/zhenxun/ui/models/presets/__init__.py @@ -0,0 +1,20 @@ +""" +预设模型模块 +包含预定义的复合组件数据模型 +""" + +from .card import InfoCardData, InfoCardMetadataItem, InfoCardSection +from .help_page import HelpCategory, HelpItem, PluginHelpPageData +from .plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem + +__all__ = [ + "HelpCategory", + "HelpItem", + "InfoCardData", + "InfoCardMetadataItem", + "InfoCardSection", + "PluginHelpPageData", + "PluginMenuCategory", + "PluginMenuData", + "PluginMenuItem", +] diff --git a/zhenxun/ui/models/presets/card.py b/zhenxun/ui/models/presets/card.py new file mode 100644 index 00000000..2d3e998a --- /dev/null +++ b/zhenxun/ui/models/presets/card.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field + +from ..core.base import RenderableComponent + +__all__ = [ + "InfoCardData", + "InfoCardMetadataItem", + "InfoCardSection", +] + + +class InfoCardMetadataItem(BaseModel): + """信息卡片元数据项""" + + label: str + value: str | int + + +class InfoCardSection(BaseModel): + """信息卡片内容区块""" + + title: str + content: list[str] = Field(..., description="内容段落列表") + + +class InfoCardData(RenderableComponent): + """通用信息卡片的数据模型""" + + style_name: str | None = None + title: str = Field(..., description="卡片主标题") + metadata: list[InfoCardMetadataItem] = Field(default_factory=list) + sections: list[InfoCardSection] = Field(default_factory=list) + + @property + def template_name(self) -> str: + """返回用于渲染此组件的Jinja2模板的路径。""" + return "components/presets/info_card/main.html" diff --git a/zhenxun/ui/models/presets/help_page.py b/zhenxun/ui/models/presets/help_page.py new file mode 100644 index 00000000..3a6c6fb8 --- /dev/null +++ b/zhenxun/ui/models/presets/help_page.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + +from ..core.base import RenderableComponent + +__all__ = [ + "HelpCategory", + "HelpItem", + "PluginHelpPageData", +] + + +class HelpItem(BaseModel): + """帮助菜单中的单个功能项""" + + name: str + description: str + usage: str + + +class HelpCategory(BaseModel): + """帮助菜单中的一个功能类别""" + + title: str + icon_svg_path: str + items: list[HelpItem] + + +class PluginHelpPageData(RenderableComponent): + """通用插件帮助页面的数据模型""" + + style_name: str | None = None + bot_nickname: str + page_title: str + categories: list[HelpCategory] + + @property + def template_name(self) -> str: + return "pages/core/help_page/main.html" diff --git a/zhenxun/ui/models/presets/plugin_menu.py b/zhenxun/ui/models/presets/plugin_menu.py new file mode 100644 index 00000000..1c440fc9 --- /dev/null +++ b/zhenxun/ui/models/presets/plugin_menu.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, Field + +from ..core.base import RenderableComponent + +__all__ = [ + "PluginMenuCategory", + "PluginMenuData", + "PluginMenuItem", +] + + +class PluginMenuItem(BaseModel): + """插件菜单中的单个插件项""" + + id: str + name: str + status: bool + has_superuser_help: bool + commands: list[str] = Field(default_factory=list) + + +class PluginMenuCategory(BaseModel): + """插件菜单中的一个分类""" + + name: str + items: list[PluginMenuItem] + + +class PluginMenuData(RenderableComponent): + """通用插件帮助菜单的数据模型""" + + style_name: str | None = None + bot_name: str + bot_avatar_url: str + is_detail: bool + plugin_count: int + active_count: int + categories: list[PluginMenuCategory] + + @property + def template_name(self) -> str: + return "pages/core/plugin_menu/main.html" diff --git a/zhenxun/utils/_image_template.py b/zhenxun/utils/_image_template.py index 327f7bc2..7f27db76 100644 --- a/zhenxun/utils/_image_template.py +++ b/zhenxun/utils/_image_template.py @@ -3,12 +3,9 @@ from io import BytesIO from pathlib import Path import random -from nonebot_plugin_htmlrender import md_to_pic, template_to_pic from PIL.ImageFont import FreeTypeFont from pydantic import BaseModel -from zhenxun.configs.path_config import TEMPLATE_PATH - from ._build_image import BuildImage @@ -286,191 +283,3 @@ class ImageTemplate: width = max(width, w) height += h return width, height - - -class MarkdownTable: - def __init__(self, headers: list[str], rows: list[list[str]]): - self.headers = headers - self.rows = rows - - def to_markdown(self) -> str: - """将表格转换为Markdown格式""" - header_row = "| " + " | ".join(self.headers) + " |" - 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 Markdown: - def __init__(self, data: list[str] | None = None): - if data is None: - data = [] - self._data = data - - def text(self, text: str) -> "Markdown": - """添加Markdown文本""" - self._data.append(text) - return self - - def head(self, text: str, level: int = 1) -> "Markdown": - """添加Markdown标题""" - if level < 1 or level > 6: - raise ValueError("标题级别必须在1到6之间") - self._data.append(f"{'#' * level} {text}") - return self - - def image(self, content: str | Path, add_empty_line: bool = True) -> "Markdown": - """添加Markdown图片 - - 参数: - content: 图片内容,可以是url地址,图片路径或base64字符串. - add_empty_line: 默认添加换行. - - 返回: - Markdown: Markdown - """ - if isinstance(content, Path): - content = str(content.absolute()) - if content.startswith("base64"): - content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}" - self._data.append(f"![image]({content})") - if add_empty_line: - self._add_empty_line() - return self - - def quote(self, text: str | list[str]) -> "Markdown": - """添加Markdown引用文本 - - 参数: - text: 引用文本内容,可以是字符串或字符串列表. - 如果是列表,则每个元素都会被单独引用。 - - 返回: - Markdown: Markdown - """ - if isinstance(text, str): - self._data.append(f"> {text}") - elif isinstance(text, list): - for t in text: - self._data.append(f"> {t}") - self._add_empty_line() - return self - - def code(self, code: str, language: str = "python") -> "Markdown": - """添加Markdown代码块""" - self._data.append(f"```{language}\n{code}\n```") - return self - - def table(self, headers: list[str], rows: list[list[str]]) -> "Markdown": - """添加Markdown表格""" - table = MarkdownTable(headers, rows) - self._data.append(table.to_markdown()) - return self - - def list(self, items: list[str | list[str]]) -> "Markdown": - """添加Markdown列表""" - self._add_empty_line() - _text = "\n".join( - f"- {item}" - if isinstance(item, str) - else "\n".join(f"- {sub_item}" for sub_item in item) - for item in items - ) - self._data.append(_text) - return self - - def _add_empty_line(self): - """添加空行""" - self._data.append("") - - async def build(self, width: int = 800, css_path: Path | None = None) -> bytes: - """构建Markdown文本""" - if css_path is not None: - return await md_to_pic( - md="\n".join(self._data), width=width, css_path=str(css_path.absolute()) - ) - return await md_to_pic(md="\n".join(self._data), width=width) - - -class Notebook: - def __init__(self, data: list[dict] | None = None): - self._data = data if data is not None else [] - - def text(self, text: str) -> "Notebook": - """添加Notebook文本""" - self._data.append({"type": "paragraph", "text": text}) - return self - - def head(self, text: str, level: int = 1) -> "Notebook": - """添加Notebook标题""" - if not 1 <= level <= 4: - raise ValueError("标题级别必须在1-4之间") - self._data.append({"type": "heading", "text": text, "level": level}) - return self - - def image( - self, - content: str | Path, - caption: str | None = None, - ) -> "Notebook": - """添加Notebook图片 - - 参数: - content: 图片内容,可以是url地址,图片路径或base64字符串. - caption: 图片说明. - - 返回: - Notebook: Notebook - """ - if isinstance(content, Path): - content = str(content.absolute()) - if content.startswith("base64"): - content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}" - self._data.append({"type": "image", "src": content, "caption": caption}) - return self - - def quote(self, text: str | list[str]) -> "Notebook": - """添加Notebook引用文本 - - 参数: - text: 引用文本内容,可以是字符串或字符串列表. - 如果是列表,则每个元素都会被单独引用。 - - 返回: - Notebook: Notebook - """ - if isinstance(text, str): - self._data.append({"type": "blockquote", "text": text}) - elif isinstance(text, list): - for t in text: - self._data.append({"type": "blockquote", "text": text}) - return self - - def code(self, code: str, language: str = "python") -> "Notebook": - """添加Notebook代码块""" - self._data.append({"type": "code", "code": code, "language": language}) - return self - - def list(self, items: list[str], ordered: bool = False) -> "Notebook": - """添加Notebook列表""" - self._data.append({"type": "list", "data": items, "ordered": ordered}) - return self - - def add_divider(self) -> None: - """添加分隔线""" - self._data.append({"type": "divider"}) - - async def build(self) -> bytes: - """构建Notebook""" - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "notebook").absolute()), - template_name="main.html", - templates={"elements": self._data}, - pages={ - "viewport": {"width": 700, "height": 10}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) diff --git a/zhenxun/utils/common_utils.py b/zhenxun/utils/common_utils.py index cfdabdc5..afc44f94 100644 --- a/zhenxun/utils/common_utils.py +++ b/zhenxun/utils/common_utils.py @@ -1,3 +1,4 @@ +import re from typing import overload from nonebot.adapters import Bot @@ -120,3 +121,17 @@ class SqlUtils: if not_null: sql += " NOT NULL" return sql + + +def format_usage_for_markdown(text: str) -> str: + """ + 智能地将Python多行字符串转换为适合Markdown渲染的格式。 + - 将单个换行符替换为Markdown的硬换行(行尾加两个空格)。 + - 保留两个或更多的连续换行符,使其成为Markdown的段落分隔。 + """ + if not text: + return "" + text = re.sub(r"\n{2,}", "<>", text) + text = text.replace("\n", " \n") + text = text.replace("<>", "\n\n") + return text diff --git a/zhenxun/utils/echart_utils/__init__.py b/zhenxun/utils/echart_utils/__init__.py index 029dc3df..a0ce0820 100644 --- a/zhenxun/utils/echart_utils/__init__.py +++ b/zhenxun/utils/echart_utils/__init__.py @@ -1,32 +1,31 @@ import os +from pathlib import Path import random -from nonebot_plugin_htmlrender import template_to_pic - -from zhenxun.configs.path_config import TEMPLATE_PATH +from zhenxun.services import renderer_service from zhenxun.utils._build_image import BuildImage from .models import Barh -BACKGROUND_PATH = TEMPLATE_PATH / "bar_chart" / "background" +BACKGROUND_PATH = ( + Path() / "resources" / "themes" / "default" / "assets" / "bar_chart" / "background" +) class ChartUtils: @classmethod async def barh(cls, data: Barh) -> BuildImage: """横向统计图""" - to_json = data.to_dict() - to_json["background_image"] = ( - f"./background/{random.choice(os.listdir(BACKGROUND_PATH))}" + background_image_name = random.choice(os.listdir(BACKGROUND_PATH)) + render_data = { + "title": data.title, + "category_data": data.category_data, + "data": data.data, + "background_image": background_image_name, + "direction": "horizontal", + } + + image_bytes = await renderer_service.render( + "components/charts/bar_chart", data=render_data ) - pic = await template_to_pic( - template_path=str((TEMPLATE_PATH / "bar_chart").absolute()), - template_name="main.html", - templates={"data": to_json}, - pages={ - "viewport": {"width": 1000, "height": 1000}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - return BuildImage.open(pic) + return BuildImage.open(image_bytes) diff --git a/zhenxun/utils/exception.py b/zhenxun/utils/exception.py index 8b3ec282..603d7c42 100644 --- a/zhenxun/utils/exception.py +++ b/zhenxun/utils/exception.py @@ -98,3 +98,11 @@ class AllURIsFailedError(Exception): for url, exc in zip(self.urls, self.exceptions) ) return f"All {len(self.urls)} URIs failed:\n{exc_info}" + + +class RenderingError(Exception): + """ + 在渲染服务无法生成图片时抛出。 + """ + + pass diff --git a/zhenxun/utils/manager/bot_profile_manager.py b/zhenxun/utils/manager/bot_profile_manager.py index 57554e75..c46fc41d 100644 --- a/zhenxun/utils/manager/bot_profile_manager.py +++ b/zhenxun/utils/manager/bot_profile_manager.py @@ -1,31 +1,26 @@ import asyncio -import os from pathlib import Path from typing import ClassVar import aiofiles import nonebot -from nonebot.compat import model_dump -from nonebot_plugin_htmlrender import template_to_pic from pydantic import BaseModel from zhenxun.configs.config import BotConfig, Config -from zhenxun.configs.path_config import DATA_PATH, TEMPLATE_PATH +from zhenxun.configs.path_config import DATA_PATH from zhenxun.configs.utils.models import PluginExtraData from zhenxun.models.statistics import Statistics from zhenxun.models.user_console import UserConsole +from zhenxun.services import renderer_service from zhenxun.services.log import logger -from zhenxun.utils._build_image import BuildImage from zhenxun.utils.platform import PlatformUtils +from zhenxun.utils.pydantic_compat import model_dump DIR_PATH = DATA_PATH / "bot_profile" PROFILE_PATH = DIR_PATH / "profile" PROFILE_PATH.mkdir(parents=True, exist_ok=True) -PROFILE_IMAGE_PATH = DIR_PATH / "image" -PROFILE_IMAGE_PATH.mkdir(parents=True, exist_ok=True) - Config.add_plugin_config( "bot_profile", @@ -66,17 +61,12 @@ class BotProfileManager: @classmethod def clear_profile_image(cls, bot_id: str | None = None): - """清除BOT自我介绍图片""" + """清除BOT自我介绍的内存缓存""" if bot_id: - file_path = PROFILE_IMAGE_PATH / f"{bot_id}.png" - if file_path.exists(): - file_path.unlink() + if bot_id in cls._bot_data: + del cls._bot_data[bot_id] else: - for f in os.listdir(PROFILE_IMAGE_PATH): - _f = PROFILE_IMAGE_PATH / f - if _f.is_file(): - _f.unlink() - cls._bot_data.clear() + cls._bot_data.clear() @classmethod async def _read_profile(cls, bot_id: str): @@ -147,11 +137,8 @@ class BotProfileManager: @classmethod async def build_bot_profile_image( cls, bot_id: str, tags: list[dict[str, str]] | None = None - ) -> Path | None: + ) -> bytes | None: """构建BOT自我介绍图片""" - file_path = PROFILE_IMAGE_PATH / f"{bot_id}.png" - if file_path.exists(): - return file_path profile, service_count, call_count = await asyncio.gather( cls.get_bot_profile(bot_id), UserConsole.get_new_uid(), @@ -164,28 +151,19 @@ class BotProfileManager: {"text": f"服务人数: {service_count}", "color": "#5e92e0"}, {"text": f"调用次数: {call_count}", "color": "#31e074"}, ] - image_bytes = await template_to_pic( - template_path=str((TEMPLATE_PATH / "bot_profile").absolute()), - template_name="main.html", - templates={ - "avatar": str(profile.avatar.absolute()) if profile.avatar else None, - "bot_name": profile.name, - "bot_description": profile.introduction, - "service_count": service_count, - "call_count": call_count, - "plugin_list": cls.get_plugin_profile(), - "tags": tags, - "title": f"{BotConfig.self_nickname}简介", - }, - pages={ - "viewport": {"width": 1077, "height": 1000}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, + profile_data = { + "avatar": profile.avatar.absolute().as_uri() if profile.avatar else None, + "bot_name": profile.name, + "bot_description": profile.introduction, + "service_count": service_count, + "call_count": call_count, + "plugin_list": cls.get_plugin_profile(), + "tags": tags, + "title": f"{BotConfig.self_nickname}简介", + } + return await renderer_service.render( + "pages/builtin/bot_profile", data=profile_data ) - image = BuildImage.open(image_bytes) - await image.save(file_path) - return file_path BotProfileManager.clear_profile_image()