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""
+
+
+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"")
- 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()