♻️ refactor: 统一图片渲染架构并引入通用UI组件系统 (#2019)
Some checks failed
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled

* ♻️ 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图片生成模块

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio 2025-08-15 16:34:37 +08:00 committed by GitHub
parent d5e5fac02d
commit 11524bcb04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 3130 additions and 2222 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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": "通过 开启/关闭群被动 来控制群被动 <br>"
+ " 示例:开启/关闭群被动早晚安 <br> 示例:开启/关闭全部群被动"
+ " <br> ---------- <br> "
+ "<br>".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", "<br>"),
"usage": data.metadata.usage.replace("\n", "<br>"),
}
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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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(" ", "&nbsp;") 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:

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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,
)

View File

@ -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)

View File

@ -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 <Provider/ModelName>` 查看详情",
column_name=column_name,
data_list=data_list,
builder = TableBuilder(
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
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 <Provider>` 重置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 <Provider>` 重置Key状态"
)
builder.set_headers(
[
"Key (部分)",
"状态",
"总调用",
"成功率",
"平均延迟(s)",
"上次错误",
"建议操作",
]
)
builder.add_rows(data_list)
return await builder.build(use_cache=False)

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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"

View File

@ -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,
)

View File

@ -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()

View File

@ -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(),

View File

@ -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 = {

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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": "通过 开启/关闭群被动 来控制群被动 <br>"
+ " 示例:开启/关闭群被动早晚安 <br> ---------- <br> "
+ "<br>".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"<br>以下为超级用户额外命令<br>{superuser_help}"
plugin_list.append(
{
"name": data.plugin.name,
"description": data.metadata.description.replace("\n", "<br>"),
"usage": data.metadata.usage.replace("\n", "<br>"),
}
)
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)

View File

@ -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)

View File

@ -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",

View File

@ -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

View File

@ -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"]

View File

@ -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)

View File

@ -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)"
)

View File

@ -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

40
zhenxun/ui/__init__.py Normal file
View File

@ -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",
]

View File

@ -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",
]

View File

@ -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,
)

View File

@ -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

View File

@ -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",
]

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -0,0 +1,14 @@
"""
预设构建器模块
包含预定义的UI组件构建器
"""
from .help_page import PluginHelpPageBuilder
from .info_card import InfoCardBuilder
from .plugin_menu import PluginMenuBuilder
__all__ = [
"InfoCardBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",
]

View File

@ -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]

View File

@ -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",
]

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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",
]

View File

@ -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

View File

@ -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="传递给模板的布局选项"
)

View File

@ -0,0 +1,124 @@
from abc import ABC, abstractmethod
from typing import Literal
from pydantic import BaseModel, Field
__all__ = [
"CodeElement",
"HeadingElement",
"ImageElement",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"QuoteElement",
"RawHtmlElement",
"TableElement",
"TextElement",
]
class MarkdownElement(BaseModel, ABC):
@abstractmethod
def to_markdown(self) -> str:
"""Serializes the element to its Markdown string representation."""
pass
class TextElement(MarkdownElement):
text: str
def to_markdown(self) -> str:
return self.text
class HeadingElement(MarkdownElement):
text: str
level: int = Field(..., ge=1, le=6)
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
src: str
alt: str = "image"
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
code: str
language: str = ""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
html: str
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
headers: list[str]
rows: list[list[str]]
alignments: list[Literal["left", "center", "right"]] | None = None
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
if self.alignments:
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
separator_row = (
"| "
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
+ " |"
)
else:
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(default_factory=list)
class QuoteElement(ContainerElement):
def to_markdown(self) -> str:
inner_md = "\n".join(part.to_markdown() for part in self.content)
return "\n".join([f"> {line}" for line in inner_md.split("\n")])
class ListItemElement(ContainerElement):
def to_markdown(self) -> str:
return "\n".join(part.to_markdown() for part in self.content)
class ListElement(ContainerElement):
ordered: bool = False
def to_markdown(self) -> str:
lines = []
for i, item in enumerate(self.content):
if isinstance(item, ListItemElement):
prefix = f"{i + 1}." if self.ordered else "*"
item_content = item.to_markdown()
lines.append(f"{prefix} {item_content}")
return "\n".join(lines)
class MarkdownData(BaseModel):
"""Markdown转图片的数据模型"""
style_name: str | None = None
markdown: str
width: int = 800
css_path: str | None = None

View File

@ -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]

View File

@ -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="数据行列表")

View File

@ -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",
]

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -3,12 +3,9 @@ from io import BytesIO
from pathlib import Path
import random
from nonebot_plugin_htmlrender import md_to_pic, template_to_pic
from PIL.ImageFont import FreeTypeFont
from pydantic import BaseModel
from zhenxun.configs.path_config import TEMPLATE_PATH
from ._build_image import BuildImage
@ -286,191 +283,3 @@ class ImageTemplate:
width = max(width, w)
height += h
return width, height
class MarkdownTable:
def __init__(self, headers: list[str], rows: list[list[str]]):
self.headers = headers
self.rows = rows
def to_markdown(self) -> str:
"""将表格转换为Markdown格式"""
header_row = "| " + " | ".join(self.headers) + " |"
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
data_rows = "\n".join(
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
)
return f"{header_row}\n{separator_row}\n{data_rows}"
class Markdown:
def __init__(self, data: list[str] | None = None):
if data is None:
data = []
self._data = data
def text(self, text: str) -> "Markdown":
"""添加Markdown文本"""
self._data.append(text)
return self
def head(self, text: str, level: int = 1) -> "Markdown":
"""添加Markdown标题"""
if level < 1 or level > 6:
raise ValueError("标题级别必须在1到6之间")
self._data.append(f"{'#' * level} {text}")
return self
def image(self, content: str | Path, add_empty_line: bool = True) -> "Markdown":
"""添加Markdown图片
参数:
content: 图片内容可以是url地址图片路径或base64字符串.
add_empty_line: 默认添加换行.
返回:
Markdown: Markdown
"""
if isinstance(content, Path):
content = str(content.absolute())
if content.startswith("base64"):
content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
self._data.append(f"![image]({content})")
if add_empty_line:
self._add_empty_line()
return self
def quote(self, text: str | list[str]) -> "Markdown":
"""添加Markdown引用文本
参数:
text: 引用文本内容可以是字符串或字符串列表.
如果是列表则每个元素都会被单独引用
返回:
Markdown: Markdown
"""
if isinstance(text, str):
self._data.append(f"> {text}")
elif isinstance(text, list):
for t in text:
self._data.append(f"> {t}")
self._add_empty_line()
return self
def code(self, code: str, language: str = "python") -> "Markdown":
"""添加Markdown代码块"""
self._data.append(f"```{language}\n{code}\n```")
return self
def table(self, headers: list[str], rows: list[list[str]]) -> "Markdown":
"""添加Markdown表格"""
table = MarkdownTable(headers, rows)
self._data.append(table.to_markdown())
return self
def list(self, items: list[str | list[str]]) -> "Markdown":
"""添加Markdown列表"""
self._add_empty_line()
_text = "\n".join(
f"- {item}"
if isinstance(item, str)
else "\n".join(f"- {sub_item}" for sub_item in item)
for item in items
)
self._data.append(_text)
return self
def _add_empty_line(self):
"""添加空行"""
self._data.append("")
async def build(self, width: int = 800, css_path: Path | None = None) -> bytes:
"""构建Markdown文本"""
if css_path is not None:
return await md_to_pic(
md="\n".join(self._data), width=width, css_path=str(css_path.absolute())
)
return await md_to_pic(md="\n".join(self._data), width=width)
class Notebook:
def __init__(self, data: list[dict] | None = None):
self._data = data if data is not None else []
def text(self, text: str) -> "Notebook":
"""添加Notebook文本"""
self._data.append({"type": "paragraph", "text": text})
return self
def head(self, text: str, level: int = 1) -> "Notebook":
"""添加Notebook标题"""
if not 1 <= level <= 4:
raise ValueError("标题级别必须在1-4之间")
self._data.append({"type": "heading", "text": text, "level": level})
return self
def image(
self,
content: str | Path,
caption: str | None = None,
) -> "Notebook":
"""添加Notebook图片
参数:
content: 图片内容可以是url地址图片路径或base64字符串.
caption: 图片说明.
返回:
Notebook: Notebook
"""
if isinstance(content, Path):
content = str(content.absolute())
if content.startswith("base64"):
content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
self._data.append({"type": "image", "src": content, "caption": caption})
return self
def quote(self, text: str | list[str]) -> "Notebook":
"""添加Notebook引用文本
参数:
text: 引用文本内容可以是字符串或字符串列表.
如果是列表则每个元素都会被单独引用
返回:
Notebook: Notebook
"""
if isinstance(text, str):
self._data.append({"type": "blockquote", "text": text})
elif isinstance(text, list):
for t in text:
self._data.append({"type": "blockquote", "text": text})
return self
def code(self, code: str, language: str = "python") -> "Notebook":
"""添加Notebook代码块"""
self._data.append({"type": "code", "code": code, "language": language})
return self
def list(self, items: list[str], ordered: bool = False) -> "Notebook":
"""添加Notebook列表"""
self._data.append({"type": "list", "data": items, "ordered": ordered})
return self
def add_divider(self) -> None:
"""添加分隔线"""
self._data.append({"type": "divider"})
async def build(self) -> bytes:
"""构建Notebook"""
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "notebook").absolute()),
template_name="main.html",
templates={"elements": self._data},
pages={
"viewport": {"width": 700, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)

View File

@ -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,}", "<<PARAGRAPH_BREAK>>", text)
text = text.replace("\n", " \n")
text = text.replace("<<PARAGRAPH_BREAK>>", "\n\n")
return text

View File

@ -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)

View File

@ -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

View File

@ -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()