mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 05:32:52 +08:00
♻️ 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
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:
parent
d5e5fac02d
commit
11524bcb04
@ -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()
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
|
||||
from zhenxun.configs.config import BotConfig, Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
from zhenxun.models.bot_console import BotConsole
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.models.level_user import LevelUser
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.statistics import Statistics
|
||||
@ -15,60 +13,114 @@ from zhenxun.services import (
|
||||
LLMException,
|
||||
LLMMessage,
|
||||
generate,
|
||||
renderer_service,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils._image_template import Markdown
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
||||
|
||||
from ._config import (
|
||||
GROUP_HELP_PATH,
|
||||
SIMPLE_DETAIL_HELP_IMAGE,
|
||||
SIMPLE_HELP_IMAGE,
|
||||
base_config,
|
||||
from zhenxun.ui import (
|
||||
InfoCardBuilder,
|
||||
NotebookBuilder,
|
||||
PluginMenuBuilder,
|
||||
PluginMenuCategory,
|
||||
)
|
||||
from .html_help import build_html_image
|
||||
from .normal_help import build_normal_image
|
||||
from .zhenxun_help import build_zhenxun_image
|
||||
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||
from zhenxun.utils.enum import BlockType, PluginType
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
from ._utils import classify_plugin
|
||||
|
||||
random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help"
|
||||
|
||||
background = IMAGE_PATH / "background" / "0.png"
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
|
||||
def _create_plugin_menu_item(
|
||||
bot: BotConsole | None,
|
||||
plugin: PluginInfo,
|
||||
group: GroupConsole | None,
|
||||
is_detail: bool,
|
||||
) -> dict:
|
||||
"""为插件菜单构造一个插件菜单项数据字典"""
|
||||
status = True
|
||||
has_superuser_help = False
|
||||
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
if extra_data.superuser_help:
|
||||
has_superuser_help = True
|
||||
|
||||
if not plugin.status:
|
||||
if plugin.block_type == BlockType.ALL:
|
||||
status = False
|
||||
elif group and plugin.block_type == BlockType.GROUP:
|
||||
status = False
|
||||
elif not group and plugin.block_type == BlockType.PRIVATE:
|
||||
status = False
|
||||
elif group and f"{plugin.module}," in group.block_plugin:
|
||||
status = False
|
||||
elif bot and f"{plugin.module}," in bot.block_plugins:
|
||||
status = False
|
||||
|
||||
commands = []
|
||||
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
commands = [cmd.command for cmd in extra_data.commands]
|
||||
|
||||
return {
|
||||
"id": str(plugin.id),
|
||||
"name": plugin.name,
|
||||
"status": status,
|
||||
"has_superuser_help": has_superuser_help,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
async def create_help_img(
|
||||
session: Uninfo, group_id: str | None, is_detail: bool
|
||||
) -> Path:
|
||||
"""生成帮助图片
|
||||
) -> bytes:
|
||||
"""使用渲染服务生成帮助图片"""
|
||||
classified_data = await classify_plugin(
|
||||
session, group_id, is_detail, _create_plugin_menu_item
|
||||
)
|
||||
|
||||
参数:
|
||||
session: Uninfo
|
||||
group_id: 群号
|
||||
"""
|
||||
help_type = base_config.get("type", "").strip().lower()
|
||||
sorted_categories = dict(
|
||||
sorted(classified_data.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
)
|
||||
categories_for_model = []
|
||||
plugin_count = 0
|
||||
active_count = 0
|
||||
|
||||
match help_type:
|
||||
case "html":
|
||||
result = BuildImage.open(
|
||||
await build_html_image(session, group_id, is_detail)
|
||||
)
|
||||
case "zhenxun":
|
||||
result = BuildImage.open(
|
||||
await build_zhenxun_image(session, group_id, is_detail)
|
||||
)
|
||||
case _:
|
||||
result = await build_normal_image(group_id, is_detail)
|
||||
if group_id:
|
||||
save_path = GROUP_HELP_PATH / f"{group_id}_{is_detail}.png"
|
||||
elif is_detail:
|
||||
save_path = SIMPLE_DETAIL_HELP_IMAGE
|
||||
else:
|
||||
save_path = SIMPLE_HELP_IMAGE
|
||||
await result.save(save_path)
|
||||
return save_path
|
||||
if sorted_categories:
|
||||
menu_key = next(iter(sorted_categories.keys()))
|
||||
max_data = sorted_categories.pop(menu_key)
|
||||
main_category_name = "主要功能" if menu_key in ["normal", "功能"] else menu_key
|
||||
categories_for_model.append({"name": main_category_name, "items": max_data})
|
||||
plugin_count += len(max_data)
|
||||
active_count += sum(1 for item in max_data if item["status"])
|
||||
|
||||
for menu, value in sorted_categories.items():
|
||||
category_name = "主要功能" if menu in ["normal", "功能"] else menu
|
||||
categories_for_model.append({"name": category_name, "items": value})
|
||||
plugin_count += len(value)
|
||||
active_count += sum(1 for item in value if item["status"])
|
||||
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
|
||||
bot_avatar_url = PlatformUtils.get_user_avatar_url(bot_id, platform) or ""
|
||||
|
||||
builder = PluginMenuBuilder(
|
||||
bot_name=BotConfig.self_nickname,
|
||||
bot_avatar_url=bot_avatar_url,
|
||||
is_detail=is_detail,
|
||||
)
|
||||
|
||||
for category in categories_for_model:
|
||||
builder.add_category(
|
||||
PluginMenuCategory(name=category["name"], items=category["items"])
|
||||
)
|
||||
|
||||
return await builder.build()
|
||||
|
||||
|
||||
async def get_user_allow_help(user_id: str) -> list[PluginType]:
|
||||
@ -92,36 +144,6 @@ async def get_user_allow_help(user_id: str) -> list[PluginType]:
|
||||
return type_list
|
||||
|
||||
|
||||
async def get_normal_help(
|
||||
metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
|
||||
) -> str | bytes:
|
||||
"""构建默认帮助详情
|
||||
|
||||
参数:
|
||||
metadata: PluginMetadata
|
||||
extra: PluginExtraData
|
||||
is_superuser: 是否超级用户帮助
|
||||
|
||||
返回:
|
||||
str | bytes: 返回信息
|
||||
"""
|
||||
items = None
|
||||
if is_superuser:
|
||||
if usage := extra.superuser_help:
|
||||
items = {
|
||||
"简介": metadata.description,
|
||||
"用法": usage,
|
||||
}
|
||||
else:
|
||||
items = {
|
||||
"简介": metadata.description,
|
||||
"用法": metadata.usage,
|
||||
}
|
||||
if items:
|
||||
return (await ImageTemplate.hl_page(metadata.name, items)).pic2bytes()
|
||||
return "该功能没有帮助信息"
|
||||
|
||||
|
||||
def min_leading_spaces(str_list: list[str]) -> int:
|
||||
min_spaces = 9999
|
||||
|
||||
@ -142,45 +164,6 @@ def split_text(text: str):
|
||||
return [s.replace(" ", " ") for s in split_text]
|
||||
|
||||
|
||||
async def get_zhenxun_help(
|
||||
module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
|
||||
) -> str | bytes:
|
||||
"""构建ZhenXun帮助详情
|
||||
|
||||
参数:
|
||||
module: 模块名
|
||||
metadata: PluginMetadata
|
||||
extra: PluginExtraData
|
||||
is_superuser: 是否超级用户帮助
|
||||
|
||||
返回:
|
||||
str | bytes: 返回信息
|
||||
"""
|
||||
call_count = await Statistics.filter(plugin_name=module).count()
|
||||
usage = metadata.usage
|
||||
if is_superuser:
|
||||
if not extra.superuser_help:
|
||||
return "该功能没有超级用户帮助信息"
|
||||
usage = extra.superuser_help
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "help_detail").absolute()),
|
||||
template_name="main.html",
|
||||
templates={
|
||||
"title": metadata.name,
|
||||
"author": extra.author,
|
||||
"version": extra.version,
|
||||
"call_count": call_count,
|
||||
"descriptions": split_text(metadata.description),
|
||||
"usages": split_text(usage),
|
||||
},
|
||||
pages={
|
||||
"viewport": {"width": 824, "height": 590},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
|
||||
|
||||
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
|
||||
"""获取功能的帮助信息
|
||||
|
||||
@ -196,16 +179,42 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
|
||||
plugin = await PluginInfo.get_or_none(
|
||||
name__iexact=name, load_status=True, plugin_type__in=type_list
|
||||
)
|
||||
|
||||
if plugin:
|
||||
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if _plugin and _plugin.metadata:
|
||||
extra_data = PluginExtraData(**_plugin.metadata.extra)
|
||||
if Config.get_config("help", "detail_type") == "zhenxun":
|
||||
return await get_zhenxun_help(
|
||||
plugin.module, _plugin.metadata, extra_data, is_superuser
|
||||
)
|
||||
else:
|
||||
return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
|
||||
|
||||
call_count = await Statistics.filter(plugin_name=plugin.module).count()
|
||||
usage = _plugin.metadata.usage
|
||||
if is_superuser:
|
||||
if not extra_data.superuser_help:
|
||||
return "该功能没有超级用户帮助信息"
|
||||
usage = extra_data.superuser_help
|
||||
|
||||
builder = InfoCardBuilder(title=_plugin.metadata.name)
|
||||
|
||||
builder.add_metadata_items(
|
||||
[
|
||||
("作者", extra_data.author or "未知"),
|
||||
("版本", extra_data.version or "未知"),
|
||||
("调用次数", call_count),
|
||||
]
|
||||
)
|
||||
|
||||
processed_description = format_usage_for_markdown(
|
||||
_plugin.metadata.description.strip()
|
||||
)
|
||||
processed_usage = format_usage_for_markdown(usage.strip())
|
||||
|
||||
builder.add_section("简介", [processed_description])
|
||||
builder.add_section("使用方法", [processed_usage])
|
||||
|
||||
style_name = Config.get_config("help", "HELP_STYLE", "default")
|
||||
render_dict = model_dump(builder._data)
|
||||
render_dict["style_name"] = style_name
|
||||
|
||||
return await renderer_service.render("pages/builtin/help", data=render_dict)
|
||||
return "糟糕! 该功能没有帮助喔..."
|
||||
return "没有查找到这个功能噢..."
|
||||
|
||||
@ -282,10 +291,12 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
|
||||
|
||||
reply_text = response.text if response else "抱歉,我暂时无法回答这个问题。"
|
||||
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
|
||||
|
||||
if len(reply_text) > threshold:
|
||||
markdown = Markdown()
|
||||
markdown.text(reply_text)
|
||||
return await markdown.build()
|
||||
builder = NotebookBuilder()
|
||||
builder.text(reply_text)
|
||||
return await builder.build()
|
||||
|
||||
return reply_text
|
||||
|
||||
except LLMException as e:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
@ -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(),
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
109
zhenxun/services/help_service.py
Normal file
109
zhenxun/services/help_service.py
Normal 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
|
||||
38
zhenxun/services/renderer/__init__.py
Normal file
38
zhenxun/services/renderer/__init__.py
Normal 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"]
|
||||
221
zhenxun/services/renderer/engines.py
Normal file
221
zhenxun/services/renderer/engines.py
Normal 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)
|
||||
40
zhenxun/services/renderer/models.py
Normal file
40
zhenxun/services/renderer/models.py
Normal 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)"
|
||||
)
|
||||
489
zhenxun/services/renderer/service.py
Normal file
489
zhenxun/services/renderer/service.py
Normal 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
40
zhenxun/ui/__init__.py
Normal 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",
|
||||
]
|
||||
19
zhenxun/ui/builders/__init__.py
Normal file
19
zhenxun/ui/builders/__init__.py
Normal 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",
|
||||
]
|
||||
41
zhenxun/ui/builders/base.py
Normal file
41
zhenxun/ui/builders/base.py
Normal 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,
|
||||
)
|
||||
88
zhenxun/ui/builders/charts.py
Normal file
88
zhenxun/ui/builders/charts.py
Normal 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
|
||||
16
zhenxun/ui/builders/core/__init__.py
Normal file
16
zhenxun/ui/builders/core/__init__.py
Normal 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",
|
||||
]
|
||||
117
zhenxun/ui/builders/core/layout.py
Normal file
117
zhenxun/ui/builders/core/layout.py
Normal 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)
|
||||
149
zhenxun/ui/builders/core/markdown.py
Normal file
149
zhenxun/ui/builders/core/markdown.py
Normal 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)
|
||||
107
zhenxun/ui/builders/core/notebook.py
Normal file
107
zhenxun/ui/builders/core/notebook.py
Normal 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
|
||||
)
|
||||
27
zhenxun/ui/builders/core/table.py
Normal file
27
zhenxun/ui/builders/core/table.py
Normal 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
|
||||
14
zhenxun/ui/builders/presets/__init__.py
Normal file
14
zhenxun/ui/builders/presets/__init__.py
Normal 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",
|
||||
]
|
||||
27
zhenxun/ui/builders/presets/help_page.py
Normal file
27
zhenxun/ui/builders/presets/help_page.py
Normal 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
|
||||
46
zhenxun/ui/builders/presets/info_card.py
Normal file
46
zhenxun/ui/builders/presets/info_card.py
Normal 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
|
||||
36
zhenxun/ui/builders/presets/plugin_menu.py
Normal file
36
zhenxun/ui/builders/presets/plugin_menu.py
Normal 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
|
||||
14
zhenxun/ui/builders/widgets/__init__.py
Normal file
14
zhenxun/ui/builders/widgets/__init__.py
Normal 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",
|
||||
]
|
||||
25
zhenxun/ui/builders/widgets/badge.py
Normal file
25
zhenxun/ui/builders/widgets/badge.py
Normal 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
|
||||
42
zhenxun/ui/builders/widgets/progress_bar.py
Normal file
42
zhenxun/ui/builders/widgets/progress_bar.py
Normal 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
|
||||
33
zhenxun/ui/builders/widgets/user_info_block.py
Normal file
33
zhenxun/ui/builders/widgets/user_info_block.py
Normal 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
|
||||
84
zhenxun/ui/models/__init__.py
Normal file
84
zhenxun/ui/models/__init__.py
Normal 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",
|
||||
]
|
||||
43
zhenxun/ui/models/charts.py
Normal file
43
zhenxun/ui/models/charts.py
Normal 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]
|
||||
17
zhenxun/ui/models/components/__init__.py
Normal file
17
zhenxun/ui/models/components/__init__.py
Normal 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",
|
||||
]
|
||||
22
zhenxun/ui/models/components/badge.py
Normal file
22
zhenxun/ui/models/components/badge.py
Normal 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"
|
||||
35
zhenxun/ui/models/components/divider.py
Normal file
35
zhenxun/ui/models/components/divider.py
Normal 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"
|
||||
24
zhenxun/ui/models/components/progress_bar.py
Normal file
24
zhenxun/ui/models/components/progress_bar.py
Normal 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"
|
||||
23
zhenxun/ui/models/components/user_info_block.py
Normal file
23
zhenxun/ui/models/components/user_info_block.py
Normal 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"
|
||||
47
zhenxun/ui/models/core/__init__.py
Normal file
47
zhenxun/ui/models/core/__init__.py
Normal 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",
|
||||
]
|
||||
20
zhenxun/ui/models/core/base.py
Normal file
20
zhenxun/ui/models/core/base.py
Normal 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
|
||||
24
zhenxun/ui/models/core/layout.py
Normal file
24
zhenxun/ui/models/core/layout.py
Normal 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="传递给模板的布局选项"
|
||||
)
|
||||
124
zhenxun/ui/models/core/markdown.py
Normal file
124
zhenxun/ui/models/core/markdown.py
Normal 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""
|
||||
|
||||
|
||||
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
|
||||
38
zhenxun/ui/models/core/notebook.py
Normal file
38
zhenxun/ui/models/core/notebook.py
Normal 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]
|
||||
59
zhenxun/ui/models/core/table.py
Normal file
59
zhenxun/ui/models/core/table.py
Normal 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="数据行列表")
|
||||
20
zhenxun/ui/models/presets/__init__.py
Normal file
20
zhenxun/ui/models/presets/__init__.py
Normal 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",
|
||||
]
|
||||
37
zhenxun/ui/models/presets/card.py
Normal file
37
zhenxun/ui/models/presets/card.py
Normal 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"
|
||||
38
zhenxun/ui/models/presets/help_page.py
Normal file
38
zhenxun/ui/models/presets/help_page.py
Normal 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"
|
||||
42
zhenxun/ui/models/presets/plugin_menu.py
Normal file
42
zhenxun/ui/models/presets/plugin_menu.py
Normal 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"
|
||||
@ -3,12 +3,9 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
|
||||
from nonebot_plugin_htmlrender import md_to_pic, template_to_pic
|
||||
from PIL.ImageFont import FreeTypeFont
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
|
||||
from ._build_image import BuildImage
|
||||
|
||||
|
||||
@ -286,191 +283,3 @@ class ImageTemplate:
|
||||
width = max(width, w)
|
||||
height += h
|
||||
return width, height
|
||||
|
||||
|
||||
class MarkdownTable:
|
||||
def __init__(self, headers: list[str], rows: list[list[str]]):
|
||||
self.headers = headers
|
||||
self.rows = rows
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""将表格转换为Markdown格式"""
|
||||
header_row = "| " + " | ".join(self.headers) + " |"
|
||||
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
|
||||
data_rows = "\n".join(
|
||||
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
|
||||
)
|
||||
return f"{header_row}\n{separator_row}\n{data_rows}"
|
||||
|
||||
|
||||
class Markdown:
|
||||
def __init__(self, data: list[str] | None = None):
|
||||
if data is None:
|
||||
data = []
|
||||
self._data = data
|
||||
|
||||
def text(self, text: str) -> "Markdown":
|
||||
"""添加Markdown文本"""
|
||||
self._data.append(text)
|
||||
return self
|
||||
|
||||
def head(self, text: str, level: int = 1) -> "Markdown":
|
||||
"""添加Markdown标题"""
|
||||
if level < 1 or level > 6:
|
||||
raise ValueError("标题级别必须在1到6之间")
|
||||
self._data.append(f"{'#' * level} {text}")
|
||||
return self
|
||||
|
||||
def image(self, content: str | Path, add_empty_line: bool = True) -> "Markdown":
|
||||
"""添加Markdown图片
|
||||
|
||||
参数:
|
||||
content: 图片内容,可以是url地址,图片路径或base64字符串.
|
||||
add_empty_line: 默认添加换行.
|
||||
|
||||
返回:
|
||||
Markdown: Markdown
|
||||
"""
|
||||
if isinstance(content, Path):
|
||||
content = str(content.absolute())
|
||||
if content.startswith("base64"):
|
||||
content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
self._data.append(f"")
|
||||
if add_empty_line:
|
||||
self._add_empty_line()
|
||||
return self
|
||||
|
||||
def quote(self, text: str | list[str]) -> "Markdown":
|
||||
"""添加Markdown引用文本
|
||||
|
||||
参数:
|
||||
text: 引用文本内容,可以是字符串或字符串列表.
|
||||
如果是列表,则每个元素都会被单独引用。
|
||||
|
||||
返回:
|
||||
Markdown: Markdown
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
self._data.append(f"> {text}")
|
||||
elif isinstance(text, list):
|
||||
for t in text:
|
||||
self._data.append(f"> {t}")
|
||||
self._add_empty_line()
|
||||
return self
|
||||
|
||||
def code(self, code: str, language: str = "python") -> "Markdown":
|
||||
"""添加Markdown代码块"""
|
||||
self._data.append(f"```{language}\n{code}\n```")
|
||||
return self
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]]) -> "Markdown":
|
||||
"""添加Markdown表格"""
|
||||
table = MarkdownTable(headers, rows)
|
||||
self._data.append(table.to_markdown())
|
||||
return self
|
||||
|
||||
def list(self, items: list[str | list[str]]) -> "Markdown":
|
||||
"""添加Markdown列表"""
|
||||
self._add_empty_line()
|
||||
_text = "\n".join(
|
||||
f"- {item}"
|
||||
if isinstance(item, str)
|
||||
else "\n".join(f"- {sub_item}" for sub_item in item)
|
||||
for item in items
|
||||
)
|
||||
self._data.append(_text)
|
||||
return self
|
||||
|
||||
def _add_empty_line(self):
|
||||
"""添加空行"""
|
||||
self._data.append("")
|
||||
|
||||
async def build(self, width: int = 800, css_path: Path | None = None) -> bytes:
|
||||
"""构建Markdown文本"""
|
||||
if css_path is not None:
|
||||
return await md_to_pic(
|
||||
md="\n".join(self._data), width=width, css_path=str(css_path.absolute())
|
||||
)
|
||||
return await md_to_pic(md="\n".join(self._data), width=width)
|
||||
|
||||
|
||||
class Notebook:
|
||||
def __init__(self, data: list[dict] | None = None):
|
||||
self._data = data if data is not None else []
|
||||
|
||||
def text(self, text: str) -> "Notebook":
|
||||
"""添加Notebook文本"""
|
||||
self._data.append({"type": "paragraph", "text": text})
|
||||
return self
|
||||
|
||||
def head(self, text: str, level: int = 1) -> "Notebook":
|
||||
"""添加Notebook标题"""
|
||||
if not 1 <= level <= 4:
|
||||
raise ValueError("标题级别必须在1-4之间")
|
||||
self._data.append({"type": "heading", "text": text, "level": level})
|
||||
return self
|
||||
|
||||
def image(
|
||||
self,
|
||||
content: str | Path,
|
||||
caption: str | None = None,
|
||||
) -> "Notebook":
|
||||
"""添加Notebook图片
|
||||
|
||||
参数:
|
||||
content: 图片内容,可以是url地址,图片路径或base64字符串.
|
||||
caption: 图片说明.
|
||||
|
||||
返回:
|
||||
Notebook: Notebook
|
||||
"""
|
||||
if isinstance(content, Path):
|
||||
content = str(content.absolute())
|
||||
if content.startswith("base64"):
|
||||
content = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
self._data.append({"type": "image", "src": content, "caption": caption})
|
||||
return self
|
||||
|
||||
def quote(self, text: str | list[str]) -> "Notebook":
|
||||
"""添加Notebook引用文本
|
||||
|
||||
参数:
|
||||
text: 引用文本内容,可以是字符串或字符串列表.
|
||||
如果是列表,则每个元素都会被单独引用。
|
||||
|
||||
返回:
|
||||
Notebook: Notebook
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
self._data.append({"type": "blockquote", "text": text})
|
||||
elif isinstance(text, list):
|
||||
for t in text:
|
||||
self._data.append({"type": "blockquote", "text": text})
|
||||
return self
|
||||
|
||||
def code(self, code: str, language: str = "python") -> "Notebook":
|
||||
"""添加Notebook代码块"""
|
||||
self._data.append({"type": "code", "code": code, "language": language})
|
||||
return self
|
||||
|
||||
def list(self, items: list[str], ordered: bool = False) -> "Notebook":
|
||||
"""添加Notebook列表"""
|
||||
self._data.append({"type": "list", "data": items, "ordered": ordered})
|
||||
return self
|
||||
|
||||
def add_divider(self) -> None:
|
||||
"""添加分隔线"""
|
||||
self._data.append({"type": "divider"})
|
||||
|
||||
async def build(self) -> bytes:
|
||||
"""构建Notebook"""
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "notebook").absolute()),
|
||||
template_name="main.html",
|
||||
templates={"elements": self._data},
|
||||
pages={
|
||||
"viewport": {"width": 700, "height": 10},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user