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

* ♻️ refactor: 统一图片渲染架构并引入通用UI组件系统

🎨 **渲染服务重构**
- 统一图片渲染入口,引入主题系统支持
- 优化Jinja2环境管理,支持主题覆盖和插件命名空间
- 新增UI缓存机制和主题重载功能

✨ **通用UI组件系统**
- 新增 zhenxun.ui 模块,提供数据模型和构建器
- 引入BaseBuilder基类,支持链式调用
- 新增多种UI构建器:InfoCard, Markdown, Table, Chart, Layout等
- 新增通用组件:Divider, Badge, ProgressBar, UserInfoBlock

🔄 **插件迁移**
- 迁移9个内置插件至新渲染系统
- 移除各插件中分散的图片生成工具
- 优化数据处理和渲染逻辑

💥 **Breaking Changes**
- 移除旧的图片渲染接口和模板路径
- TEMPLATE_PATH 更名为 THEMES_PATH
- 插件需适配新的RendererService和zhenxun.ui模块

* ✅ test(check): 更新自检插件测试中的渲染服务模拟

* ♻️ refactor(renderer): 将缓存文件名哈希算法切换到 SHA256

* ♻️ refactor(shop): 移除商店HTML图片生成模块

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2025-08-15 16:34:37 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent d5e5fac02d
commit 11524bcb04
78 changed files with 3130 additions and 2222 deletions
+65 -7
View File
@@ -1,4 +1,5 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime, timedelta
import inspect
@@ -7,26 +8,26 @@ from types import MappingProxyType
from typing import Any, Literal
from nonebot.adapters import Bot, Event
from nonebot.compat import model_dump
from nonebot_plugin_alconna import At, UniMessage, UniMsg
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel, Field, create_model
from tortoise.expressions import Q
from zhenxun.configs.config import BotConfig
from zhenxun.models.friend_user import FriendUser
from zhenxun.models.goods_info import GoodsInfo
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.user_console import UserConsole
from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.models.user_props_log import UserPropsLog
from zhenxun.services import renderer_service
from zhenxun.services.log import logger
from zhenxun.utils.enum import GoldHandle, PropHandle
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump
from .config import ICON_PATH, PLATFORM_PATH, base_config
from .html_image import html_image
from .normal_image import normal_image
from .config import ICON_PATH, PLATFORM_PATH
class Goods(BaseModel):
@@ -150,9 +151,7 @@ class ShopManage:
@classmethod
async def get_shop_image(cls) -> bytes:
if base_config.get("style") == "zhenxun":
return await html_image()
return await normal_image()
return await prepare_shop_data()
@classmethod
def __build_params(
@@ -565,3 +564,62 @@ class ShopManage:
"""
user = await UserConsole.get_user(user_id, platform)
return user.gold
def get_limit_time(end_time: int) -> str | None:
now = int(time.time())
if now > end_time or end_time == 0:
return None
time_difference = datetime.fromtimestamp(end_time) - datetime.fromtimestamp(now)
total_seconds = time_difference.total_seconds()
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
return f"{hours}:{minutes:02d}"
def get_discount(price: int, discount: float) -> int | None:
return None if discount == 1.0 else int(price * discount)
async def prepare_shop_data() -> bytes:
"""准备商店数据并调用渲染服务"""
goods_list = (
await GoodsInfo.filter(
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
)
.annotate()
.order_by("id")
.all()
)
partition_dict: dict[str, list[dict]] = defaultdict(list)
for idx, goods in enumerate(goods_list):
partition_name = goods.partition or "默认分区"
icon_asset_path = None
if goods.icon and (ICON_PATH / goods.icon).exists():
icon_asset_path = f"image/shop_icon/{goods.icon}"
goods_item = {
"id": idx + 1,
"name": goods.goods_name,
"description": goods.goods_description,
"price": goods.goods_price,
"discount_price": get_discount(goods.goods_price, goods.goods_discount),
"limit_time": get_limit_time(goods.goods_limit_time),
"daily_limit": goods.daily_limit or "∞",
"icon_url": icon_asset_path,
}
partition_dict[partition_name].append(goods_item)
categories = [
{"partition_title": partition, "goods_list": items}
for partition, items in partition_dict.items()
]
shop_data = {
"bot_nickname": BotConfig.self_nickname,
"categories": categories,
}
return await renderer_service.render("pages/builtin/shop", data=shop_data)
+2 -2
View File
@@ -1,5 +1,5 @@
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
from zhenxun.configs.path_config import IMAGE_PATH, THEMES_PATH
base_config = Config.get("shop")
@@ -17,4 +17,4 @@ PLATFORM_PATH = {
LEFT_RIGHT_IMAGE = ["1.png", "2.png", "qq.png"]
LEFT_RIGHT_PATH = TEMPLATE_PATH / "shop" / "res" / "img"
LEFT_RIGHT_PATH = THEMES_PATH / "default" / "assets" / "shop" / "img"
@@ -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()