♻️ refactor(UI): 重构UI渲染服务为组件化分层架构 (#2025)
检查bot是否运行正常 / bot check (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
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

* ♻️ refactor(UI): 重构UI渲染服务为组件化分层架构

♻️ **架构重构**
- UI渲染服务重构为组件化分层架构
- 解耦主题管理、HTML生成、截图功能

✨ **新增功能**
- `zhenxun.ui` 统一入口,提供 `render`、`markdown`、`vstack` 等API
- `RenderableComponent` 基类和渲染协议抽象
- 新增主题管理器和截图引擎模块

⚙️ **配置优化**
- UI配置迁移至 `superuser/ui_manager.py`
- 新增"重载UI主题"管理指令

🔧 **性能改进**
- 优化渲染缓存,支持组件级透明缓存
- 所有UI组件适配新渲染流程

* 🚨 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-18 23:08:22 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent 11524bcb04
commit 6124e217d0
43 changed files with 1334 additions and 928 deletions
@@ -14,12 +14,13 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession
import pytz
from zhenxun import ui
from zhenxun.configs.config import Config
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.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
@@ -174,7 +175,7 @@ async def _(
builder = TableBuilder(f"消息排行({count.result})", date_str)
builder.set_headers(column_name).add_rows(rows_data)
image_bytes = await builder.build()
image_bytes = await ui.render(builder.build())
logger.info(
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
+4 -3
View File
@@ -5,10 +5,10 @@ from nonebot.plugin import PluginMetadata
from nonebot.rule import Rule, to_me
from nonebot_plugin_alconna import Alconna, on_alconna
from zhenxun import ui
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
from zhenxun.utils.rules import notice_rule
@@ -68,8 +68,9 @@ async def handle_self_check():
try:
data_dict = await get_status_info()
image_bytes = await renderer_service.render(
"pages/builtin/check", data=data_dict
image_bytes = await ui.render_template(
"pages/builtin/check",
data=data_dict,
)
await MessageUtils.build_message(image_bytes).send()
+6 -6
View File
@@ -1,6 +1,7 @@
import nonebot
from nonebot_plugin_uninfo import Uninfo
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.configs.utils import PluginExtraData
@@ -13,15 +14,14 @@ from zhenxun.services import (
LLMException,
LLMMessage,
generate,
renderer_service,
)
from zhenxun.services.log import logger
from zhenxun.ui import (
from zhenxun.ui.builders import (
InfoCardBuilder,
NotebookBuilder,
PluginMenuBuilder,
PluginMenuCategory,
)
from zhenxun.ui.models import PluginMenuCategory
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
@@ -120,7 +120,7 @@ async def create_help_img(
PluginMenuCategory(name=category["name"], items=category["items"])
)
return await builder.build()
return await ui.render(builder.build())
async def get_user_allow_help(user_id: str) -> list[PluginType]:
@@ -214,7 +214,7 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
render_dict = model_dump(builder._data)
render_dict["style_name"] = style_name
return await renderer_service.render("pages/builtin/help", data=render_dict)
return await ui.render_template("pages/builtin/help", data=render_dict)
return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..."
@@ -295,7 +295,7 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
if len(reply_text) > threshold:
builder = NotebookBuilder()
builder.text(reply_text)
return await builder.build()
return await ui.render(builder.build())
return reply_text
+2 -2
View File
@@ -5,12 +5,12 @@ from nonebot_plugin_uninfo import Uninfo
from tortoise.expressions import RawSQL
from tortoise.functions import Count
from zhenxun import ui
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 = [
@@ -198,4 +198,4 @@ async def get_user_info(
},
}
return await renderer_service.render("pages/builtin/my_info", data=profile_data)
return await ui.render_template("pages/builtin/my_info", data=profile_data)
@@ -1,9 +1,10 @@
from typing import Any
from zhenxun.services import renderer_service
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.types import ModelModality
from zhenxun.ui import MarkdownBuilder, TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
from zhenxun.ui.models.core.table import StatusBadgeCell, TextCell
def _format_seconds(seconds: int) -> str:
@@ -35,7 +36,7 @@ class Presenters:
builder = TableBuilder(
title=title, tip="当前没有配置任何LLM模型。"
).set_headers(["提供商", "模型名称", "API类型", "状态"])
return await builder.build()
return await renderer_service.render(builder.build())
column_name = ["提供商", "模型名称", "API类型", "状态"]
data_list = []
@@ -60,7 +61,7 @@ class Presenters:
)
builder.set_headers(column_name)
builder.add_rows(data_list)
return await builder.build(use_cache=True)
return await renderer_service.render(builder.build(), use_cache=True)
@staticmethod
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
@@ -99,7 +100,7 @@ class Presenters:
builder.text(f"- **最大Token**: {token_value}")
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
return await builder.with_style("light").build()
return await renderer_service.render(builder.with_style("light").build())
@staticmethod
async def format_key_status_as_image(
@@ -181,4 +182,4 @@ class Presenters:
]
)
builder.add_rows(data_list)
return await builder.build(use_cache=False)
return await renderer_service.render(builder.build(), use_cache=False)
@@ -6,9 +6,9 @@ from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_uninfo import Uninfo
from nonebot_plugin_waiter import prompt_until
from zhenxun import ui
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
@@ -193,7 +193,7 @@ async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
render_data = {"page_type": "user", "payload": user_payload}
image_bytes = await renderer_service.render(
image_bytes = await ui.render_template(
"pages/builtin/mahiro_bank",
data=render_data,
viewport={"width": 386, "height": 10},
@@ -209,7 +209,7 @@ async def _(session: Uninfo, arparma: Arparma):
render_data = {"page_type": "overview", "payload": overview_payload}
image_bytes = await renderer_service.render(
image_bytes = await ui.render_template(
"pages/builtin/mahiro_bank",
data=render_data,
viewport={"width": 450, "height": 10},
@@ -84,6 +84,7 @@ async def _(session: EventSession):
try:
result = await StoreManager.get_plugins_info()
logger.info("查看插件列表", "插件商店", session=session)
await MessageUtils.build_message([*result]).send()
except Exception as e:
logger.error(f"查看插件列表失败 e: {e}", "插件商店", session=session, e=e)
@@ -5,12 +5,14 @@ import shutil
from aiocache import cached
import ujson as json
from zhenxun import ui
from zhenxun.builtin_plugins.plugin_store.models import StorePluginInfo
from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger
from zhenxun.services.plugin_init import PluginInitManager
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import RepoFileManager
from zhenxun.utils.repo_utils.models import RepoFileInfo, RepoType
@@ -25,22 +27,6 @@ from .config import (
from .exceptions import PluginStoreException
def row_style(column: str, text: str) -> RowStyle:
"""被动技能文本风格
参数:
column: 表头
text: 文本内容
返回:
RowStyle: RowStyle
"""
style = RowStyle()
if column == "-" and text == "已安装":
style.font_color = "#67C23A"
return style
class StoreManager:
@classmethod
@cached(60)
@@ -105,61 +91,123 @@ class StoreManager:
return await PluginInfo.filter(load_status=True).values_list(*args)
@classmethod
async def get_plugins_info(cls) -> list[BuildImage] | str:
async def get_plugins_info(cls) -> list[bytes] | str:
"""插件列表
返回:
BuildImage | str: 返回消息
bytes | str: 返回消息
"""
plugin_list, extra_plugin_list = await cls.get_data()
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "0.1") for p in db_plugin_list}
HIGHLIGHT_COLOR = "#E6A23C"
structured_native_list = []
structured_extra_list = []
index = 0
data_list = []
extra_data_list = []
for plugin_info in plugin_list:
data_list.append(
[
"已安装" if plugin_info.module in suc_plugin else "",
index,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
structured_native_list.append(
{
"is_installed": plugin_info.module in suc_plugin,
"id": index,
"name": plugin_info.name,
"description": plugin_info.description,
"author": plugin_info.author,
"version_str": cls.version_check(plugin_info, suc_plugin),
"type_name": plugin_info.plugin_type_name,
"has_update": not is_new and plugin_info.module in suc_plugin,
}
)
index += 1
for plugin_info in extra_plugin_list:
extra_data_list.append(
[
"已安装" if plugin_info.module in suc_plugin else "",
index,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
structured_extra_list.append(
{
"is_installed": plugin_info.module in suc_plugin,
"id": index,
"name": plugin_info.name,
"description": plugin_info.description,
"author": plugin_info.author,
"version_str": cls.version_check(plugin_info, suc_plugin),
"type_name": plugin_info.plugin_type_name,
"has_update": not is_new and plugin_info.module in suc_plugin,
}
)
index += 1
return [
await ImageTemplate.table_page(
"原生插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
data_list,
text_style=row_style,
),
await ImageTemplate.table_page(
"第三方插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
extra_data_list,
text_style=row_style,
),
]
native_table_builder = TableBuilder(
title="原生插件列表", tip="通过添加/移除插件 ID 来管理插件"
).set_headers(column_name)
native_rows_data = []
for row_data in structured_native_list:
row_color = HIGHLIGHT_COLOR if row_data["has_update"] else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if row_data["is_installed"]
else TextCell(content="")
)
native_rows_data.append(
[
status_cell,
TextCell(content=str(row_data["id"]), color=row_color),
TextCell(content=row_data["name"], color=row_color),
TextCell(content=row_data["description"], color=row_color),
TextCell(content=row_data["author"], color=row_color),
TextCell(
content=row_data["version_str"],
color=row_color,
bold=bool(row_color),
),
TextCell(content=row_data["type_name"], color=row_color),
]
)
native_table_builder.add_rows(native_rows_data)
native_table_bytes = await ui.render(
native_table_builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
extra_table_builder = TableBuilder(
title="第三方插件列表", tip="通过添加/移除插件 ID 来管理插件"
).set_headers(column_name)
extra_rows_data = []
for row_data in structured_extra_list:
row_color = HIGHLIGHT_COLOR if row_data["has_update"] else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if row_data["is_installed"]
else TextCell(content="")
)
extra_rows_data.append(
[
status_cell,
TextCell(content=str(row_data["id"]), color=row_color),
TextCell(content=row_data["name"], color=row_color),
TextCell(content=row_data["description"], color=row_color),
TextCell(content=row_data["author"], color=row_color),
TextCell(
content=row_data["version_str"],
color=row_color,
bold=bool(row_color),
),
TextCell(content=row_data["type_name"], color=row_color),
]
)
extra_table_builder.add_rows(extra_rows_data)
extra_table_bytes = await ui.render(
extra_table_builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
return [native_table_bytes, extra_table_bytes]
@classmethod
async def get_plugin_by_value(
@@ -290,7 +338,6 @@ class StoreManager:
await VirtualEnvPackageManager.install_requirement(requirement_file)
if not is_install_req:
# 从仓库根目录查找文件
rand = random.randint(1, 10000)
requirement_path = TEMP_PATH / f"plugin_store_{rand}_req.txt"
requirements_path = TEMP_PATH / f"plugin_store_{rand}_reqs.txt"
@@ -345,19 +392,20 @@ class StoreManager:
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod
async def search_plugin(cls, plugin_name_or_author: str) -> BuildImage | str:
async def search_plugin(cls, plugin_name_or_author: str) -> bytes | str:
"""搜索插件
参数:
plugin_name_or_author: 插件名称或作者
返回:
BuildImage | str: 返回消息
bytes | str: 返回消息
"""
plugin_list, extra_plugin_list = await cls.get_data()
all_plugin_list = plugin_list + extra_plugin_list
db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
filtered_data = [
(id, plugin_info)
for id, plugin_info in enumerate(all_plugin_list)
@@ -365,28 +413,50 @@ class StoreManager:
or plugin_name_or_author.lower() in plugin_info.author.lower()
]
data_list = [
[
"已安装" if plugin_info.module in suc_plugin else "",
id,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
for id, plugin_info in filtered_data
]
if not data_list:
if not filtered_data:
return "未找到相关插件..."
HIGHLIGHT_COLOR = "#E6A23C"
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
return await ImageTemplate.table_page(
"商店插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
data_list,
text_style=row_style,
builder = TableBuilder(
title=f"插件搜索结果: '{plugin_name_or_author}'",
tip="通过添加/移除插件 ID 来管理插件",
)
builder.set_headers(column_name)
rows_to_add = []
for id, plugin_info in filtered_data:
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
has_update = not is_new and plugin_info.module in suc_plugin
row_color = HIGHLIGHT_COLOR if has_update else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if plugin_info.module in suc_plugin
else TextCell(content="")
)
rows_to_add.append(
[
status_cell,
TextCell(content=str(id), color=row_color),
TextCell(content=plugin_info.name, color=row_color),
TextCell(content=plugin_info.description, color=row_color),
TextCell(content=plugin_info.author, color=row_color),
TextCell(
content=cls.version_check(plugin_info, suc_plugin),
color=row_color,
bold=has_update,
),
TextCell(content=plugin_info.plugin_type_name, color=row_color),
]
)
builder.add_rows(rows_to_add)
render_viewport = {"width": 1400, "height": 10}
return await ui.render(builder.build(), viewport=render_viewport)
@classmethod
async def update_plugin(cls, index_or_module: str) -> str:
+2 -2
View File
@@ -13,6 +13,7 @@ from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel, Field, create_model
from tortoise.expressions import Q
from zhenxun import ui
from zhenxun.configs.config import BotConfig
from zhenxun.models.friend_user import FriendUser
from zhenxun.models.goods_info import GoodsInfo
@@ -20,7 +21,6 @@ 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
@@ -622,4 +622,4 @@ async def prepare_shop_data() -> bytes:
"categories": categories,
}
return await renderer_service.render("pages/builtin/shop", data=shop_data)
return await ui.render_template("pages/builtin/shop", data=shop_data)
+2 -2
View File
@@ -9,10 +9,10 @@ from nonebot.drivers import Driver
from nonebot_plugin_uninfo import Uninfo
import pytz
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.services import renderer_service
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils
@@ -281,7 +281,7 @@ async def _generate_html_card(
"last_sign_date_str": last_sign_date_str,
}
image_bytes = await renderer_service.render("pages/builtin/sign", data=card_data)
image_bytes = await ui.render_template("pages/builtin/sign", data=card_data)
async with aiofiles.open(card_file, "wb") as f:
await f.write(image_bytes)
@@ -7,7 +7,6 @@ from zhenxun.models.statistics import Statistics
from zhenxun.utils.echart_utils import ChartUtils
from zhenxun.utils.echart_utils.models import Barh
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage
from zhenxun.utils.time_utils import TimeUtils
@@ -60,7 +59,7 @@ class StatisticsManage:
@classmethod
async def get_global_statistics(
cls, plugin_name: str | None, day: int | None, title: str
) -> BuildImage | str:
) -> bytes | str:
query = Statistics
if plugin_name:
query = query.filter(plugin_name=plugin_name)
@@ -114,7 +113,7 @@ class StatisticsManage:
)
@classmethod
async def __build_image(cls, data_list: list[tuple[str, int]], title: str):
async def __build_image(cls, data_list: list[tuple[str, int]], title: str) -> bytes:
module2count = {x[0]: x[1] for x in data_list}
plugin_info = await PluginInfo.filter(
module__in=module2count.keys(),
@@ -8,7 +8,6 @@ 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
@@ -56,9 +55,6 @@ _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)
@@ -0,0 +1,62 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import renderer_service
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata(
name="UI管理",
description="管理UI、主题和渲染服务的相关配置",
usage="""
指令:
重载UI主题
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
module="UI",
key="THEME",
value="default",
help="设置渲染服务使用的全局主题名称(对应 resources/themes/下的目录名)",
default_value="default",
type=str,
),
RegisterConfig(
module="UI",
key="CACHE",
value=True,
help="是否为渲染服务生成的图片启用文件缓存",
default_value=True,
type=bool,
),
],
).to_dict(),
)
_matcher = on_alconna(
Alconna("重载主题"),
rule=to_me(),
permission=SUPERUSER,
priority=1,
block=True,
)
@_matcher.handle()
async def _(arparma: Arparma):
theme_name = await renderer_service.reload_theme()
logger.info(
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
)
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'!").send(
reply_to=True
)