mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 05:32:52 +08:00
✨ feat!(ui): 重构图表组件架构,实现数据与样式分离 (#2035)
* ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离 🏗️ **架构重构** - 移除charts.py中所有硬编码样式参数(grid、tooltip、legend等) - 将样式配置迁移至主题层style.json文件 - 统一图表模板消费样式文件的能力 📊 **图表组件优化** - bar_chart: 移除grid和坐标轴show参数 - pie_chart: 移除tooltip、legend样式和series视觉参数 - line_chart: 移除tooltip、grid和坐标轴配置 - radar_chart: 移除tooltip硬编码 🎨 **主题系统增强** - 新增pie_chart、line_chart、radar_chart的style.json配置 - 更新bar_chart/style.json,添加grid、xAxis、yAxis样式 - 所有图表模板支持deepMerge样式合并逻辑 🔧 **Breaking Changes** - 图表工厂函数不再接受样式参数 - 主题开发者现可通过style.json完全定制图表外观 - 提升组件可维护性和主题灵活性 * 📦️ build(pyinstaller): 引入 resources.spec 并更新 .gitignore 规则 * 🚨 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
d9e65057cf
commit
7472cabd48
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,6 +32,7 @@ MANIFEST
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!resources.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
1
resources.spec
Normal file
1
resources.spec
Normal file
@ -0,0 +1 @@
|
||||
require_resources_version: ">=1.0.0"
|
||||
@ -109,8 +109,11 @@ async def _(
|
||||
)
|
||||
|
||||
if name.available:
|
||||
help_style = Config.get_config("help", "HELP_STYLE")
|
||||
variant = help_style if help_style != "default" else None
|
||||
|
||||
traditional_help_result = await get_plugin_help(
|
||||
session.user.id, name.result, _is_superuser
|
||||
session.user.id, name.result, _is_superuser, variant=variant
|
||||
)
|
||||
|
||||
is_plugin_found = not (
|
||||
|
||||
@ -17,7 +17,6 @@ from zhenxun.services import (
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.builders import (
|
||||
InfoCardBuilder,
|
||||
NotebookBuilder,
|
||||
PluginMenuBuilder,
|
||||
)
|
||||
@ -25,7 +24,6 @@ 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
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
from ._utils import classify_plugin
|
||||
|
||||
@ -164,13 +162,16 @@ def split_text(text: str):
|
||||
return [s.replace(" ", " ") for s in split_text]
|
||||
|
||||
|
||||
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
|
||||
async def get_plugin_help(
|
||||
user_id: str, name: str, is_superuser: bool, variant: str | None = None
|
||||
) -> str | bytes:
|
||||
"""获取功能的帮助信息
|
||||
|
||||
参数:
|
||||
user_id: 用户id
|
||||
name: 插件名称或id
|
||||
is_superuser: 是否为超级用户
|
||||
variant: 使用的皮肤/变体名称
|
||||
"""
|
||||
type_list = await get_user_allow_help(user_id)
|
||||
if name.isdigit():
|
||||
@ -192,29 +193,32 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
|
||||
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),
|
||||
]
|
||||
)
|
||||
metadata_items = [
|
||||
{"label": "作者", "value": extra_data.author or "未知"},
|
||||
{"label": "版本", "value": extra_data.version or "未知"},
|
||||
{"label": "调用次数", "value": 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])
|
||||
sections = [
|
||||
{"title": "简介", "content": [processed_description]},
|
||||
{"title": "使用方法", "content": [processed_usage]},
|
||||
]
|
||||
|
||||
style_name = Config.get_config("help", "HELP_STYLE", "default")
|
||||
render_dict = model_dump(builder._data)
|
||||
render_dict["style_name"] = style_name
|
||||
page_data = {
|
||||
"title": _plugin.metadata.name,
|
||||
"metadata": metadata_items,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
return await ui.render_template("pages/builtin/help", data=render_dict)
|
||||
component = ui.template("pages/builtin/help", data=page_data)
|
||||
if variant:
|
||||
component.variant = variant
|
||||
return await ui.render(component, use_cache=True, device_scale_factor=2)
|
||||
return "糟糕! 该功能没有帮助喔..."
|
||||
return "没有查找到这个功能噢..."
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ from zhenxun.services import renderer_service
|
||||
from zhenxun.services.llm.core import KeyStatus
|
||||
from zhenxun.services.llm.types import ModelModality
|
||||
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
|
||||
from zhenxun.ui.models.core.table import StatusBadgeCell, TextCell
|
||||
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||
|
||||
|
||||
def _format_seconds(seconds: int) -> str:
|
||||
@ -39,20 +39,19 @@ class Presenters:
|
||||
return await renderer_service.render(builder.build())
|
||||
|
||||
column_name = ["提供商", "模型名称", "API类型", "状态"]
|
||||
data_list = []
|
||||
rows_data = []
|
||||
for model in models:
|
||||
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(
|
||||
rows_data.append(
|
||||
[
|
||||
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,
|
||||
StatusBadgeCell(
|
||||
text="可用" if is_available else "不可用",
|
||||
status_type="ok" if is_available else "error",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@ -60,7 +59,8 @@ class Presenters:
|
||||
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
|
||||
)
|
||||
builder.set_headers(column_name)
|
||||
builder.add_rows(data_list)
|
||||
builder.set_column_alignments(["left", "left", "left", "center"])
|
||||
builder.add_rows(rows_data)
|
||||
return await renderer_service.render(builder.build(), use_cache=True)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -7,11 +7,9 @@ import aiofiles
|
||||
import nonebot
|
||||
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.utils.manager.priority_manager import PriorityLifecycle
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
@ -176,18 +174,17 @@ async def _generate_html_card(
|
||||
|
||||
impression = float(user.impression)
|
||||
user_console = await user.user_console
|
||||
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:]}"
|
||||
if user_console and user_console.uid is not None:
|
||||
uid = f"{user_console.uid}".rjust(12, "0")
|
||||
uid_formatted = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
|
||||
else:
|
||||
uid_formatted = "XXXX XXXX XXXX"
|
||||
|
||||
level, next_impression, previous_impression = get_level_and_next_impression(
|
||||
impression
|
||||
)
|
||||
|
||||
attitude = level2attitude.get(str(level), "未知")
|
||||
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
|
||||
interpolation_val = max(0, next_impression - impression)
|
||||
interpolation = f"{interpolation_val:.2f}"
|
||||
|
||||
@ -200,16 +197,21 @@ async def _generate_html_card(
|
||||
|
||||
hour = now.hour
|
||||
if 6 < hour < 10:
|
||||
bot_message = random.choice(MORNING_MESSAGE)
|
||||
message = random.choice(MORNING_MESSAGE)
|
||||
elif 0 <= hour < 6:
|
||||
bot_message = random.choice(LG_MESSAGE)
|
||||
message = random.choice(LG_MESSAGE)
|
||||
else:
|
||||
bot_message = f"{BotConfig.self_nickname}希望你开心!"
|
||||
message = f"{BotConfig.self_nickname}希望你开心!"
|
||||
bot_message = f"{BotConfig.self_nickname}说: {message}"
|
||||
|
||||
temperature = random.randint(1, 40)
|
||||
weather_icon_name = f"{random.randint(0, 11)}.png"
|
||||
tag_icon_name = f"{random.randint(0, 5)}.png"
|
||||
|
||||
font_size = 45
|
||||
if len(nickname) > 6:
|
||||
font_size = 27
|
||||
|
||||
user_info = {
|
||||
"nickname": nickname,
|
||||
"uid_str": uid_formatted,
|
||||
@ -218,11 +220,17 @@ async def _generate_html_card(
|
||||
)
|
||||
or "",
|
||||
"sign_count": user.sign_count,
|
||||
"font_size": font_size,
|
||||
}
|
||||
|
||||
favorability_info = {
|
||||
"current": impression,
|
||||
"level": level,
|
||||
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
|
||||
"attitude": f"对你的态度: {level2attitude.get(str(level), '未知')}",
|
||||
"relation": lik2relation.get(str(level), "未知"),
|
||||
"heart2": [1 for _ in range(level)],
|
||||
"heart1": [1 for _ in range(len(lik2level) - level - 1)],
|
||||
"next_level_at": next_impression,
|
||||
"previous_level_at": previous_impression,
|
||||
}
|
||||
@ -241,15 +249,14 @@ async def _generate_html_card(
|
||||
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}"
|
||||
last_sign_date_str = ""
|
||||
|
||||
reward_info = {
|
||||
"impression": f"好感度排名第 {rank} 位",
|
||||
"gold": f"总金币:{total_gold}",
|
||||
"gift": "",
|
||||
"is_double": False,
|
||||
}
|
||||
|
||||
else:
|
||||
reward_info = {
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
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 nonebot_plugin_alconna import (
|
||||
Alconna,
|
||||
AlconnaMatch,
|
||||
Args,
|
||||
Arparma,
|
||||
Match,
|
||||
Subcommand,
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.log import logger
|
||||
@ -14,7 +23,9 @@ __plugin_meta__ = PluginMetadata(
|
||||
description="管理UI、主题和渲染服务的相关配置",
|
||||
usage="""
|
||||
指令:
|
||||
重载UI主题
|
||||
ui reload / 重载主题: 重新加载当前主题的配置和资源。
|
||||
ui theme / 主题列表: 显示所有可用的主题,并高亮显示当前主题。
|
||||
ui theme [主题名称] / 切换主题 [主题名称]: 将UI主题切换为指定主题。
|
||||
""".strip(),
|
||||
extra=PluginExtraData(
|
||||
author="HibiKier",
|
||||
@ -37,22 +48,39 @@ __plugin_meta__ = PluginMetadata(
|
||||
default_value=True,
|
||||
type=bool,
|
||||
),
|
||||
RegisterConfig(
|
||||
module="UI",
|
||||
key="DEBUG_MODE",
|
||||
value=False,
|
||||
help="是否在日志中输出渲染组件的完整HTML源码,用于调试",
|
||||
default_value=False,
|
||||
type=bool,
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
|
||||
_matcher = on_alconna(
|
||||
Alconna("重载主题"),
|
||||
ui_matcher = on_alconna(
|
||||
Alconna(
|
||||
"ui",
|
||||
Subcommand("reload", help_text="重载当前主题"),
|
||||
Subcommand("theme", Args["theme_name?", str], help_text="查看或切换主题"),
|
||||
),
|
||||
aliases={"主题管理"},
|
||||
rule=to_me(),
|
||||
permission=SUPERUSER,
|
||||
priority=1,
|
||||
block=True,
|
||||
)
|
||||
|
||||
ui_matcher.shortcut("重载主题", command="ui reload")
|
||||
ui_matcher.shortcut("主题列表", command="ui theme")
|
||||
ui_matcher.shortcut("切换主题", command="ui theme", arguments=["{%0}"])
|
||||
|
||||
@_matcher.handle()
|
||||
async def _(arparma: Arparma):
|
||||
|
||||
@ui_matcher.assign("reload")
|
||||
async def handle_reload(arparma: Arparma):
|
||||
theme_name = await renderer_service.reload_theme()
|
||||
logger.info(
|
||||
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
|
||||
@ -60,3 +88,55 @@ async def _(arparma: Arparma):
|
||||
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'!").send(
|
||||
reply_to=True
|
||||
)
|
||||
|
||||
|
||||
@ui_matcher.assign("theme")
|
||||
async def handle_theme(
|
||||
arparma: Arparma, theme_name_match: Match[str] = AlconnaMatch("theme_name")
|
||||
):
|
||||
if theme_name_match.available:
|
||||
new_theme_name = theme_name_match.result
|
||||
try:
|
||||
await renderer_service.switch_theme(new_theme_name)
|
||||
logger.info(
|
||||
f"UI主题已切换为: {new_theme_name}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
)
|
||||
await MessageUtils.build_message(
|
||||
f"🎨 主题已成功切换为 '{new_theme_name}'!"
|
||||
).send(reply_to=True)
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(
|
||||
f"尝试切换到不存在的主题: {new_theme_name}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
)
|
||||
await MessageUtils.build_message(str(e)).send(reply_to=True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"切换主题时发生错误: {e}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
e=e,
|
||||
)
|
||||
await MessageUtils.build_message(f"切换主题失败: {e}").send(reply_to=True)
|
||||
else:
|
||||
try:
|
||||
available_themes = renderer_service.list_available_themes()
|
||||
current_theme = Config.get_config("UI", "THEME", "default")
|
||||
|
||||
theme_list_str = "\n".join(
|
||||
f" - {theme}{' <- 当前' if theme == current_theme else ''}"
|
||||
for theme in sorted(available_themes)
|
||||
)
|
||||
response = f"🎨 可用主题列表:\n{theme_list_str}"
|
||||
await MessageUtils.build_message(response).send(reply_to=True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"获取主题列表时发生错误: {e}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
e=e,
|
||||
)
|
||||
await MessageUtils.build_message("获取主题列表失败。").send(reply_to=True)
|
||||
|
||||
13
zhenxun/services/renderer/config.py
Normal file
13
zhenxun/services/renderer/config.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""
|
||||
渲染器服务的共享配置和常量
|
||||
"""
|
||||
|
||||
RESERVED_TEMPLATE_KEYS: set[str] = {
|
||||
"data",
|
||||
"theme",
|
||||
"theme_css",
|
||||
"extra_css",
|
||||
"required_scripts",
|
||||
"required_styles",
|
||||
"frameless",
|
||||
}
|
||||
@ -33,6 +33,10 @@ class TemplateManifest(BaseModel):
|
||||
entrypoint: str = Field(
|
||||
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
|
||||
)
|
||||
styles: list[str] | str | None = Field(
|
||||
None,
|
||||
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
|
||||
)
|
||||
render_options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
@ -9,26 +9,50 @@ from pydantic import BaseModel
|
||||
class Renderable(ABC):
|
||||
"""
|
||||
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
|
||||
|
||||
该协议确保了所有UI组件都能被 `RendererService` 以统一的方式处理。
|
||||
任何想要被渲染服务处理的UI数据模型都应直接或间接实现此协议。
|
||||
"""
|
||||
|
||||
component_css: str | None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def template_name(self) -> str:
|
||||
"""组件声明它需要哪个模板文件。"""
|
||||
"""
|
||||
返回用于渲染此组件的Jinja2模板的路径。
|
||||
这是一个抽象属性,所有子类都必须覆盖它。
|
||||
|
||||
返回:
|
||||
str: 指向模板文件的相对路径,例如 'components/core/table'。
|
||||
"""
|
||||
...
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""
|
||||
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
|
||||
|
||||
此方法会在组件的数据被传递给模板之前调用。
|
||||
适合用于执行数据库查询、网络请求等耗时操作,以准备最终的渲染数据。
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
"""
|
||||
[新增] 返回一个包含所有直接子组件的可迭代对象。
|
||||
|
||||
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
|
||||
非容器组件应返回一个空列表。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
|
||||
return []
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于assets目录)。"""
|
||||
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
@ -36,13 +60,22 @@ class Renderable(ABC):
|
||||
"""
|
||||
返回一个将传递给模板的数据字典。
|
||||
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
|
||||
|
||||
返回:
|
||||
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
|
||||
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
|
||||
"""
|
||||
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
|
||||
可以返回 str 或 awaitable[str]。
|
||||
|
||||
参数:
|
||||
context: 当前的渲染上下文对象,可用于访问主题管理器等。
|
||||
|
||||
返回:
|
||||
str | Awaitable[str]: 注入到页面的额外CSS字符串。
|
||||
"""
|
||||
return ""
|
||||
|
||||
@ -50,6 +83,8 @@ class Renderable(ABC):
|
||||
class ScreenshotEngine(Protocol):
|
||||
"""
|
||||
一个协议,定义了截图引擎的核心能力。
|
||||
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
|
||||
而无需修改上层渲染服务的代码。
|
||||
"""
|
||||
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
@ -60,6 +95,9 @@ class ScreenshotEngine(Protocol):
|
||||
html: 要渲染的HTML内容。
|
||||
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
|
||||
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片字节数据。
|
||||
"""
|
||||
...
|
||||
|
||||
@ -67,6 +105,7 @@ class ScreenshotEngine(Protocol):
|
||||
class RenderResult(BaseModel):
|
||||
"""
|
||||
渲染服务的统一返回类型。
|
||||
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
|
||||
"""
|
||||
|
||||
image_bytes: bytes | None = None
|
||||
|
||||
32
zhenxun/services/renderer/registry.py
Normal file
32
zhenxun/services/renderer/registry.py
Normal file
@ -0,0 +1,32 @@
|
||||
# File: zhenxun/services/renderer/registry.py
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class AssetRegistry:
|
||||
"""一个独立的、用于存储由插件动态注册的资源的单例服务。"""
|
||||
|
||||
_markdown_styles: ClassVar[dict[str, Path]] = {}
|
||||
|
||||
def register_markdown_style(self, name: str, path: Path):
|
||||
"""
|
||||
为 Markdown 渲染器注册一个具名样式。
|
||||
|
||||
参数:
|
||||
name (str): 样式的唯一名称。
|
||||
path (Path): 指向该样式的CSS文件路径。
|
||||
"""
|
||||
if name in self._markdown_styles:
|
||||
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
|
||||
self._markdown_styles[name] = path
|
||||
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
|
||||
|
||||
def resolve_markdown_style(self, name: str) -> Path | None:
|
||||
"""解析已注册的 Markdown 样式。"""
|
||||
return self._markdown_styles.get(name)
|
||||
|
||||
|
||||
asset_registry = AssetRegistry()
|
||||
@ -1,13 +1,18 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiofiles
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
TemplateNotFound,
|
||||
select_autoescape,
|
||||
)
|
||||
from nonebot.utils import is_coroutine_callable
|
||||
@ -17,33 +22,101 @@ 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 zhenxun.utils.pydantic_compat import _dump_pydantic_obj
|
||||
|
||||
from .config import RESERVED_TEMPLATE_KEYS
|
||||
from .engine import get_screenshot_engine
|
||||
from .protocols import Renderable, RenderResult, ScreenshotEngine
|
||||
from .theme import ThemeManager
|
||||
from .registry import asset_registry
|
||||
from .theme import RelativePathEnvironment, ThemeManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderContext:
|
||||
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
|
||||
|
||||
renderer: "RendererService"
|
||||
theme_manager: ThemeManager
|
||||
screenshot_engine: ScreenshotEngine
|
||||
component: Renderable
|
||||
use_cache: bool
|
||||
render_options: dict[str, Any]
|
||||
resolved_template_paths: dict[str, str] = field(default_factory=dict)
|
||||
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
|
||||
collected_asset_styles: set[str] = field(default_factory=set)
|
||||
collected_scripts: set[str] = field(default_factory=set)
|
||||
collected_inline_css: list[str] = field(default_factory=list)
|
||||
processed_components: set[int] = field(default_factory=set)
|
||||
|
||||
|
||||
class RendererService:
|
||||
"""
|
||||
图片渲染服务的统一门面。
|
||||
|
||||
负责编排和调用底层渲染服务,提供统一的渲染接口。
|
||||
支持多种渲染方式:组件渲染、模板渲染等。
|
||||
作为UI渲染的中心枢纽,负责编排和调用底层服务,提供统一的渲染接口。
|
||||
主要职责包括:
|
||||
- 管理和加载UI主题 (通过 ThemeManager)。
|
||||
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML。
|
||||
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片。
|
||||
- 处理插件注册的模板、过滤器和全局函数。
|
||||
- (可选) 管理渲染结果的缓存。
|
||||
"""
|
||||
|
||||
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
|
||||
|
||||
def __init__(self):
|
||||
self._jinja_env: Environment | None = None
|
||||
self._theme_manager: ThemeManager | None = None
|
||||
self._screenshot_engine: ScreenshotEngine | None = None
|
||||
self._initialized = False
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._custom_filters: dict[str, Callable] = {}
|
||||
self._custom_globals: dict[str, Callable] = {}
|
||||
self._markdown_styles: dict[str, Path] = {}
|
||||
|
||||
self.filter("dump_json")(self._pydantic_tojson_filter)
|
||||
|
||||
def _create_jinja_env(self) -> Environment:
|
||||
"""
|
||||
创建并配置 Jinja2 渲染环境。
|
||||
|
||||
构建一个完整的 Jinja2 环境,包含:
|
||||
- PrefixLoader:用于插件模板的命名空间加载
|
||||
- FileSystemLoader:用于主题模板的文件系统加载
|
||||
- RelativePathEnvironment:支持模板间相对路径引用的自定义环境
|
||||
|
||||
返回:
|
||||
Environment: 完全配置好的 Jinja2 环境实例,准备接收自定义过滤器和全局函数。
|
||||
"""
|
||||
prefix_loader = PrefixLoader(
|
||||
{
|
||||
namespace: FileSystemLoader(str(path.absolute()))
|
||||
for namespace, path in self._plugin_template_paths.items()
|
||||
}
|
||||
)
|
||||
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
|
||||
final_loader = ChoiceLoader([prefix_loader, theme_loader])
|
||||
|
||||
env = RelativePathEnvironment(
|
||||
loader=final_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
return env
|
||||
|
||||
def register_template_namespace(self, namespace: str, path: Path):
|
||||
"""[新增] 插件注册模板路径的入口点"""
|
||||
"""
|
||||
为插件注册一个Jinja2模板命名空间。
|
||||
|
||||
这允许插件在自己的目录中维护模板,并通过
|
||||
`{% include '@namespace/template.html' %}` 的方式引用它们,
|
||||
避免了与核心或其他插件的模板命名冲突。
|
||||
|
||||
参数:
|
||||
namespace: 插件的唯一命名空间,例如插件名。
|
||||
path: 包含该插件模板的目录路径。
|
||||
"""
|
||||
if namespace in self._plugin_template_paths:
|
||||
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
|
||||
if not path.is_dir():
|
||||
@ -52,18 +125,25 @@ class RendererService:
|
||||
|
||||
def register_markdown_style(self, name: str, path: Path):
|
||||
"""
|
||||
为 Markdown 渲染器注册一个具名样式。
|
||||
为 Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)。
|
||||
|
||||
参数:
|
||||
name (str): 样式的唯一名称,例如 'cyberpunk'。
|
||||
path (Path): 指向该样式的CSS文件路径。
|
||||
"""
|
||||
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}'")
|
||||
asset_registry.register_markdown_style(name, path)
|
||||
|
||||
def filter(self, name: str) -> Callable:
|
||||
"""
|
||||
装饰器:注册一个自定义 Jinja2 过滤器。
|
||||
|
||||
参数:
|
||||
name: 过滤器在模板中的调用名称。
|
||||
|
||||
返回:
|
||||
Callable: 用于装饰过滤器函数的装饰器。
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@ -78,6 +158,12 @@ class RendererService:
|
||||
def global_function(self, name: str) -> Callable:
|
||||
"""
|
||||
装饰器:注册一个自定义 Jinja2 全局函数。
|
||||
|
||||
参数:
|
||||
name: 函数在模板中的调用名称。
|
||||
|
||||
返回:
|
||||
Callable: 用于装饰全局函数的装饰器。
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@ -90,46 +176,143 @@ class RendererService:
|
||||
return decorator
|
||||
|
||||
async def initialize(self):
|
||||
"""[新增] 延迟初始化方法,在 on_startup 钩子中调用"""
|
||||
"""
|
||||
[新增] 延迟初始化方法,在 on_startup 钩子中调用。
|
||||
|
||||
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
|
||||
使用锁来防止并发初始化。
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._jinja_env = self._create_jinja_env()
|
||||
|
||||
self._jinja_env.filters.update(self._custom_filters)
|
||||
self._jinja_env.globals.update(self._custom_globals)
|
||||
|
||||
self._screenshot_engine = get_screenshot_engine()
|
||||
self._theme_manager = ThemeManager(
|
||||
self._plugin_template_paths,
|
||||
self._custom_filters,
|
||||
self._custom_globals,
|
||||
self._markdown_styles,
|
||||
)
|
||||
|
||||
self._theme_manager = ThemeManager(self._jinja_env)
|
||||
|
||||
current_theme_name = Config.get_config("UI", "THEME", "default")
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
self._initialized = True
|
||||
|
||||
async def _collect_dependencies_recursive(
|
||||
self, component: Renderable, context: "RenderContext"
|
||||
):
|
||||
"""
|
||||
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
|
||||
|
||||
这是实现组件化样式和脚本管理的基础,确保即使是深层嵌套的组件
|
||||
所需的资源也能被正确加载到最终的HTML页面中。
|
||||
"""
|
||||
component_id = id(component)
|
||||
if component_id in context.processed_components:
|
||||
return
|
||||
context.processed_components.add(component_id)
|
||||
|
||||
component_path_base = str(component.template_name)
|
||||
manifest = await context.theme_manager.get_template_manifest(
|
||||
component_path_base
|
||||
)
|
||||
|
||||
style_paths_to_load = []
|
||||
if manifest and manifest.styles:
|
||||
styles = (
|
||||
[manifest.styles]
|
||||
if isinstance(manifest.styles, str)
|
||||
else manifest.styles
|
||||
)
|
||||
for style_path in styles:
|
||||
full_style_path = str(Path(component_path_base) / style_path).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
style_paths_to_load.append(full_style_path)
|
||||
else:
|
||||
resolved_template_name = (
|
||||
await context.theme_manager._resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
)
|
||||
conventional_style_path = str(
|
||||
Path(resolved_template_name).with_name("style.css")
|
||||
).replace("\\", "/")
|
||||
style_paths_to_load.append(conventional_style_path)
|
||||
|
||||
for css_template_path in style_paths_to_load:
|
||||
try:
|
||||
css_template = context.theme_manager.jinja_env.get_template(
|
||||
css_template_path
|
||||
)
|
||||
theme_context = {
|
||||
"theme": context.theme_manager.jinja_env.globals.get("theme", {})
|
||||
}
|
||||
css_content = await css_template.render_async(**theme_context)
|
||||
context.collected_inline_css.append(css_content)
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
|
||||
context.collected_scripts.update(component.get_required_scripts())
|
||||
context.collected_asset_styles.update(component.get_required_styles())
|
||||
|
||||
if hasattr(component, "get_extra_css"):
|
||||
res = component.get_extra_css(context)
|
||||
css_str = await res if inspect.isawaitable(res) else str(res)
|
||||
if css_str:
|
||||
context.collected_inline_css.append(css_str)
|
||||
|
||||
for child in component.get_children():
|
||||
if child:
|
||||
await self._collect_dependencies_recursive(child, context)
|
||||
|
||||
async def _render_component(
|
||||
self, component: Renderable, use_cache: bool = False, **render_options
|
||||
self,
|
||||
context: "RenderContext",
|
||||
) -> RenderResult:
|
||||
"""
|
||||
核心的私有渲染方法,执行完整的渲染流程。
|
||||
|
||||
执行步骤:
|
||||
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
|
||||
并尝试从文件系统中读取缓存图片。
|
||||
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
|
||||
异步数据加载。
|
||||
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
|
||||
收集所有需要的CSS文件、JS文件和内联CSS。
|
||||
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
|
||||
此步骤会处理独立模板和主题内模板两种情况。
|
||||
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
|
||||
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
|
||||
"""
|
||||
return await self._apply_caching_layer(self._render_component_core, context)
|
||||
|
||||
async def _apply_caching_layer(
|
||||
self,
|
||||
core_render_func: Callable[..., Awaitable[RenderResult]],
|
||||
context: "RenderContext",
|
||||
) -> RenderResult:
|
||||
"""
|
||||
一个高阶函数,为核心渲染逻辑提供缓存层。
|
||||
它负责处理缓存的读取和写入,而将实际的渲染工作委托给传入的函数。
|
||||
"""
|
||||
cache_path = None
|
||||
if Config.get_config("UI", "CACHE") and use_cache:
|
||||
component = context.component
|
||||
|
||||
if Config.get_config("UI", "CACHE") and context.use_cache:
|
||||
try:
|
||||
template_name = component.template_name
|
||||
data_dict = component.get_render_data()
|
||||
|
||||
resolved_data_dict = {}
|
||||
for key, value in data_dict.items():
|
||||
if is_coroutine_callable(value): # type: ignore
|
||||
resolved_data_dict[key] = await value
|
||||
else:
|
||||
resolved_data_dict[key] = value
|
||||
|
||||
data_str = json.dumps(resolved_data_dict, sort_keys=True)
|
||||
|
||||
cache_key_str = f"{template_name}:{data_str}"
|
||||
cache_filename = (
|
||||
f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png"
|
||||
@ -148,43 +331,46 @@ class RendererService:
|
||||
logger.warning(f"UI缓存读取失败: {e}", e=e)
|
||||
cache_path = None
|
||||
|
||||
result = await core_render_func(context)
|
||||
|
||||
if (
|
||||
Config.get_config("UI", "CACHE")
|
||||
and context.use_cache
|
||||
and cache_path
|
||||
and result.image_bytes
|
||||
):
|
||||
try:
|
||||
async with aiofiles.open(cache_path, "wb") as f:
|
||||
await f.write(result.image_bytes)
|
||||
logger.debug(f"UI缓存写入成功: {cache_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"UI缓存写入失败: {e}", e=e)
|
||||
|
||||
return result
|
||||
|
||||
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
|
||||
"""
|
||||
纯粹的核心渲染逻辑,不包含任何缓存处理。
|
||||
此方法负责从组件数据模型生成最终的图片字节和HTML。
|
||||
"""
|
||||
component = context.component
|
||||
|
||||
try:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
if hasattr(component, "prepare"):
|
||||
await component.prepare()
|
||||
|
||||
required_scripts = set(component.get_required_scripts())
|
||||
required_styles = set(component.get_required_styles())
|
||||
|
||||
if hasattr(component, "required_scripts"):
|
||||
required_scripts.update(getattr(component, "required_scripts"))
|
||||
if hasattr(component, "required_styles"):
|
||||
required_styles.update(getattr(component, "required_styles"))
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
|
||||
component_render_options = data_dict.get("render_options", {})
|
||||
if not isinstance(component_render_options, dict):
|
||||
component_render_options = {}
|
||||
|
||||
manifest_options = {}
|
||||
if manifest := await self._theme_manager.get_template_manifest(
|
||||
component.template_name
|
||||
):
|
||||
manifest_options = manifest.render_options or {}
|
||||
assert context.theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
if (
|
||||
getattr(component, "_is_standalone_template", False)
|
||||
and hasattr(component, "template_path")
|
||||
hasattr(component, "template_path")
|
||||
and isinstance(
|
||||
template_path := getattr(component, "template_path"), Path
|
||||
template_path := getattr(component, "template_path"),
|
||||
Path,
|
||||
)
|
||||
and template_path.is_absolute()
|
||||
):
|
||||
await component.prepare()
|
||||
|
||||
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
|
||||
|
||||
template_dir = template_path.parent
|
||||
@ -195,45 +381,69 @@ class RendererService:
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
temp_env.globals["theme"] = self._theme_manager.jinja_env.globals.get(
|
||||
"theme", {}
|
||||
temp_env.globals.update(context.theme_manager.jinja_env.globals)
|
||||
temp_env.globals["asset"] = (
|
||||
context.theme_manager._create_standalone_asset_loader(template_dir)
|
||||
)
|
||||
temp_env.filters["md"] = self._theme_manager._markdown_filter
|
||||
temp_env.filters["md"] = context.theme_manager._markdown_filter
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
template = temp_env.get_template(template_path.name)
|
||||
html_content = await template.render_async(data=data_dict)
|
||||
|
||||
template_context = {
|
||||
"theme": context.theme_manager.jinja_env.globals.get("theme", {}),
|
||||
"data": data_dict,
|
||||
}
|
||||
for key, value in data_dict.items():
|
||||
if key in RESERVED_TEMPLATE_KEYS:
|
||||
logger.warning(
|
||||
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
|
||||
f"在模板 '{component.template_name}' 中请使用 "
|
||||
f"'data.{key}' 访问。"
|
||||
)
|
||||
else:
|
||||
template_context[key] = value
|
||||
html_content = await template.render_async(**template_context)
|
||||
|
||||
component_render_options = data_dict.get("render_options", {})
|
||||
if not isinstance(component_render_options, dict):
|
||||
component_render_options = {}
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(render_options)
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
image_bytes = await self._screenshot_engine.render(
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=template_dir,
|
||||
**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 RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
|
||||
else:
|
||||
await component.prepare()
|
||||
await self._collect_dependencies_recursive(component, context)
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
component_render_options = data_dict.get("render_options", {})
|
||||
if not isinstance(component_render_options, dict):
|
||||
component_render_options = {}
|
||||
|
||||
manifest_options = {}
|
||||
if manifest := await context.theme_manager.get_template_manifest(
|
||||
component.template_name
|
||||
):
|
||||
manifest_options = manifest.render_options or {}
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(manifest_options)
|
||||
final_render_options.update(render_options)
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
if not self._theme_manager.current_theme:
|
||||
if not context.theme_manager.current_theme:
|
||||
raise RenderingError("渲染失败:主题未被正确加载。")
|
||||
|
||||
html_content = await self._theme_manager._render_component_to_html(
|
||||
component,
|
||||
required_scripts=list(required_scripts),
|
||||
required_styles=list(required_styles),
|
||||
html_content = await context.theme_manager._render_component_to_html(
|
||||
context,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
@ -241,20 +451,12 @@ class RendererService:
|
||||
screenshot_options.pop("extra_css", None)
|
||||
screenshot_options.pop("frameless", None)
|
||||
|
||||
image_bytes = await self._screenshot_engine.render(
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=THEMES_PATH.parent,
|
||||
**screenshot_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 RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
|
||||
except Exception as e:
|
||||
@ -271,27 +473,37 @@ class RendererService:
|
||||
self,
|
||||
component: Renderable,
|
||||
use_cache: bool = False,
|
||||
debug_mode: Literal["none", "log"] = "none",
|
||||
**render_options,
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的、多态的渲染入口,直接返回图片字节。
|
||||
|
||||
参数:
|
||||
component: 一个 Renderable 实例 (如 RenderableComponent) 或一个
|
||||
模板路径字符串。
|
||||
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**render_options: 传递给底层渲染引擎的额外参数。
|
||||
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 当渲染流程中任何步骤失败时抛出。
|
||||
"""
|
||||
result = await self._render_component(
|
||||
component,
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
screenshot_engine=self._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
**render_options,
|
||||
render_options=render_options,
|
||||
)
|
||||
if debug_mode == "log" and result.html_content:
|
||||
result = await self._render_component(context)
|
||||
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
|
||||
logger.info(
|
||||
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
|
||||
f"{result.html_content}\n"
|
||||
@ -301,17 +513,44 @@ class RendererService:
|
||||
raise RenderingError("渲染成功但未能生成图片字节数据。")
|
||||
return result.image_bytes
|
||||
|
||||
async def render_to_html(self, component: Renderable) -> str:
|
||||
"""调试方法:只执行到HTML生成步骤。"""
|
||||
async def render_to_html(
|
||||
self, component: Renderable, frameless: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
调试方法:只执行到HTML生成步骤,不进行截图。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例。
|
||||
frameless: 是否以无边框模式渲染(只渲染HTML片段)。
|
||||
|
||||
返回:
|
||||
str: 最终渲染出的完整HTML字符串。
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
return await self._theme_manager._render_component_to_html(component)
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
screenshot_engine=self._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=False,
|
||||
render_options={"frameless": frameless},
|
||||
)
|
||||
await self._collect_dependencies_recursive(component, context)
|
||||
return await self._theme_manager._render_component_to_html(
|
||||
context, frameless=frameless
|
||||
)
|
||||
|
||||
async def reload_theme(self) -> str:
|
||||
"""
|
||||
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
|
||||
这在开发主题时非常有用,可以热重载主题更改。
|
||||
|
||||
返回:
|
||||
str: 已成功加载的主题名称。
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
@ -321,3 +560,37 @@ class RendererService:
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
|
||||
return current_theme_name
|
||||
|
||||
def list_available_themes(self) -> list[str]:
|
||||
"""获取所有可用主题的列表。"""
|
||||
if not self._initialized or not self._theme_manager:
|
||||
raise RuntimeError("ThemeManager尚未初始化。")
|
||||
return self._theme_manager.list_available_themes()
|
||||
|
||||
async def switch_theme(self, theme_name: str) -> str:
|
||||
"""
|
||||
切换UI主题,加载新主题并持久化配置。
|
||||
|
||||
返回:
|
||||
str: 已成功切换到的主题名称。
|
||||
"""
|
||||
if not self._initialized or not self._theme_manager:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None
|
||||
|
||||
available_themes = self._theme_manager.list_available_themes()
|
||||
if theme_name not in available_themes:
|
||||
raise FileNotFoundError(
|
||||
f"主题 '{theme_name}' 不存在。可用主题: {', '.join(available_themes)}"
|
||||
)
|
||||
|
||||
await self._theme_manager.load_theme(theme_name)
|
||||
Config.set_config("UI", "THEME", theme_name, auto_save=True)
|
||||
logger.info(f"UI主题已切换为: {theme_name}")
|
||||
return theme_name
|
||||
|
||||
@staticmethod
|
||||
def _pydantic_tojson_filter(obj: Any) -> str:
|
||||
"""一个能够递归处理Pydantic模型及其集合的 tojson 过滤器"""
|
||||
dumped_obj = _dump_pydantic_obj(obj)
|
||||
return json.dumps(dumped_obj, ensure_ascii=False)
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiofiles
|
||||
from jinja2 import (
|
||||
@ -10,9 +12,10 @@ from jinja2 import (
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
TemplateNotFound,
|
||||
select_autoescape,
|
||||
pass_context,
|
||||
)
|
||||
import markdown
|
||||
from markupsafe import Markup
|
||||
from pydantic import BaseModel
|
||||
import ujson as json
|
||||
|
||||
@ -20,9 +23,30 @@ from zhenxun.configs.path_config import THEMES_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.renderer.models import TemplateManifest
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
from zhenxun.utils.exception import RenderingError
|
||||
from zhenxun.services.renderer.registry import asset_registry
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .service import RenderContext
|
||||
|
||||
from .config import RESERVED_TEMPLATE_KEYS
|
||||
|
||||
|
||||
class RelativePathEnvironment(Environment):
|
||||
"""
|
||||
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
|
||||
"""
|
||||
|
||||
def join_path(self, template: str, parent: str) -> str:
|
||||
"""
|
||||
如果模板路径以 './' 或 '../' 开头,则视为相对于父模板的路径进行解析。
|
||||
否则,使用默认的解析行为。
|
||||
"""
|
||||
if template.startswith("./") or template.startswith("../"):
|
||||
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
|
||||
return path.replace(os.path.sep, "/")
|
||||
return super().join_path(template, parent)
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
name: str
|
||||
@ -33,41 +57,185 @@ class Theme(BaseModel):
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
def __init__(
|
||||
self,
|
||||
plugin_template_paths: dict[str, Path],
|
||||
custom_filters: dict[str, Callable],
|
||||
custom_globals: dict[str, Callable],
|
||||
markdown_styles: dict[str, Path],
|
||||
):
|
||||
prefix_loader = PrefixLoader(
|
||||
{
|
||||
namespace: FileSystemLoader(str(path.absolute()))
|
||||
for namespace, path in plugin_template_paths.items()
|
||||
}
|
||||
)
|
||||
theme_loader = FileSystemLoader(
|
||||
[
|
||||
str(THEMES_PATH / "current_theme_placeholder" / "templates"),
|
||||
str(THEMES_PATH / "default" / "templates"),
|
||||
]
|
||||
)
|
||||
final_loader = ChoiceLoader([prefix_loader, theme_loader])
|
||||
def __init__(self, env: Environment):
|
||||
"""
|
||||
主题管理器,负责UI主题的加载、解析和模板渲染。
|
||||
|
||||
self.jinja_env = Environment(
|
||||
loader=final_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
主要职责:
|
||||
- 加载和管理UI主题,包括 `palette.json` (调色板) 和 `theme.css.jinja`(主题样式)
|
||||
- 配置和持有核心的 Jinja2 环境实例。
|
||||
- 向 Jinja2 环境注入全局函数,如 `asset()` 和 `render()`,供模板使用。
|
||||
- 实现`asset()`函数的资源解析逻辑,支持皮肤、组件、主题和默认主题之间的资源回退
|
||||
- 封装将 `Renderable` 组件渲染为最终HTML的复杂逻辑。
|
||||
"""
|
||||
self.jinja_env = env
|
||||
self.current_theme: Theme | None = None
|
||||
self._custom_filters = custom_filters
|
||||
self._custom_globals = custom_globals
|
||||
self._markdown_styles = markdown_styles
|
||||
|
||||
self.jinja_env.globals["render"] = self._global_render_component
|
||||
self.jinja_env.globals["asset"] = self._create_asset_loader()
|
||||
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
|
||||
|
||||
self.jinja_env.filters["md"] = self._markdown_filter
|
||||
|
||||
def list_available_themes(self) -> list[str]:
|
||||
"""扫描主题目录并返回所有可用的主题名称。"""
|
||||
if not THEMES_PATH.is_dir():
|
||||
return []
|
||||
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
|
||||
|
||||
def _find_component_root(self, start_path: Path) -> Path:
|
||||
"""
|
||||
从给定的起始路径向上查找,直到找到包含 manifest.json 的目录。
|
||||
这被认为是组件的根目录。如果找不到,则返回起始路径的目录。
|
||||
"""
|
||||
current_path = start_path.parent
|
||||
for _ in range(len(current_path.parts)):
|
||||
if (current_path / "manifest.json").exists():
|
||||
return current_path
|
||||
if current_path.parent == current_path:
|
||||
break
|
||||
current_path = current_path.parent
|
||||
return start_path.parent
|
||||
|
||||
def _create_asset_loader(
|
||||
self, local_base_path: Path | None = None
|
||||
) -> Callable[..., str]:
|
||||
"""
|
||||
创建并返回一个用于解析静态资源的闭包函数 (Jinja2中的 `asset()` 函数)。
|
||||
|
||||
该函数实现了强大的资源解析回退逻辑,查找顺序如下:
|
||||
1. **相对路径 (`./`)**: 优先查找相对于当前模板的 `assets` 目录。
|
||||
- 这支持组件皮肤 (`skins/`) 对其资源的覆盖。
|
||||
2. **当前主题**: 在当前激活主题的 `assets` 目录中查找。
|
||||
3. **默认主题**: 如果当前主题未找到,则回退到 `default` 主题的 `assets` 目录。
|
||||
|
||||
参数:
|
||||
local_base_path: (可选) 当渲染独立模板时,提供模板所在的目录。
|
||||
"""
|
||||
|
||||
@pass_context
|
||||
def asset_loader(ctx, asset_path: str) -> str:
|
||||
if asset_path.startswith("./"):
|
||||
parent_template_name = ctx.environment.get_template(ctx.name).name
|
||||
parent_template_abs_path = Path(
|
||||
ctx.environment.loader.get_source(
|
||||
ctx.environment, parent_template_name
|
||||
)[1]
|
||||
)
|
||||
|
||||
if (
|
||||
"/skins/" in parent_template_abs_path.as_posix()
|
||||
or "\\skins\\" in parent_template_abs_path.as_posix()
|
||||
):
|
||||
skin_dir = parent_template_abs_path.parent
|
||||
skin_asset_path = skin_dir / "assets" / asset_path[2:]
|
||||
if skin_asset_path.exists():
|
||||
logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'")
|
||||
return skin_asset_path.absolute().as_uri()
|
||||
logger.debug(
|
||||
f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源"
|
||||
)
|
||||
|
||||
component_root = self._find_component_root(parent_template_abs_path)
|
||||
|
||||
local_asset = component_root / "assets" / asset_path[2:]
|
||||
if local_asset.exists():
|
||||
logger.debug(f"找到组件公共资源: '{local_asset}'")
|
||||
return local_asset.absolute().as_uri()
|
||||
|
||||
logger.warning(
|
||||
f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。"
|
||||
)
|
||||
return ""
|
||||
|
||||
assert self.current_theme is not None
|
||||
current_theme_asset = self.current_theme.assets_dir / asset_path
|
||||
if current_theme_asset.exists():
|
||||
return current_theme_asset.absolute().as_uri()
|
||||
|
||||
default_theme_asset = self.current_theme.default_assets_dir / asset_path
|
||||
if default_theme_asset.exists():
|
||||
return default_theme_asset.absolute().as_uri()
|
||||
|
||||
logger.warning(
|
||||
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
|
||||
f"{asset_path}"
|
||||
)
|
||||
return ""
|
||||
|
||||
return asset_loader
|
||||
|
||||
def _create_standalone_asset_loader(
|
||||
self, local_base_path: Path
|
||||
) -> Callable[[str], str]:
|
||||
"""
|
||||
[新增] 为独立模板创建一个专用的、更简单的 asset loader。
|
||||
"""
|
||||
|
||||
def asset_loader(asset_path: str) -> str:
|
||||
if asset_path.startswith("./"):
|
||||
local_file = local_base_path / "assets" / asset_path[2:]
|
||||
if local_file.exists():
|
||||
return local_file.absolute().as_uri()
|
||||
logger.warning(
|
||||
f"独立模板本地资源 '{asset_path}' 在 "
|
||||
f"'{local_base_path / 'assets'}' 中未找到。"
|
||||
)
|
||||
return ""
|
||||
|
||||
assert self.current_theme is not None
|
||||
current_theme_asset = self.current_theme.assets_dir / asset_path
|
||||
if current_theme_asset.exists():
|
||||
return current_theme_asset.absolute().as_uri()
|
||||
|
||||
default_theme_asset = self.current_theme.default_assets_dir / asset_path
|
||||
if default_theme_asset.exists():
|
||||
return default_theme_asset.absolute().as_uri()
|
||||
|
||||
logger.warning(
|
||||
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
|
||||
f"{asset_path}"
|
||||
)
|
||||
return ""
|
||||
|
||||
return asset_loader
|
||||
|
||||
async def _global_render_component(self, component: Renderable | None) -> str:
|
||||
"""
|
||||
一个全局的Jinja2函数,用于在模板内部渲染子组件
|
||||
它封装了查找模板、设置上下文和渲染的逻辑。
|
||||
"""
|
||||
if not component:
|
||||
return ""
|
||||
try:
|
||||
|
||||
class MockContext:
|
||||
def __init__(self):
|
||||
self.resolved_template_paths = {}
|
||||
self.theme_manager = self
|
||||
|
||||
mock_context = MockContext()
|
||||
template_path = await self._resolve_component_template(
|
||||
component,
|
||||
mock_context, # type: ignore
|
||||
)
|
||||
template = self.jinja_env.get_template(template_path)
|
||||
|
||||
template_context = {
|
||||
"data": component,
|
||||
"frameless": True,
|
||||
}
|
||||
render_data = component.get_render_data()
|
||||
template_context.update(render_data)
|
||||
|
||||
return Markup(await template.render_async(**template_context))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"在全局 render 函数中渲染组件 '{component.__class__.__name__}' 失败",
|
||||
e=e,
|
||||
)
|
||||
return f"<!-- 组件渲染失败{component.__class__.__name__}: {e} -->"
|
||||
|
||||
@staticmethod
|
||||
def _markdown_filter(text: str) -> str:
|
||||
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
|
||||
@ -95,18 +263,30 @@ class ThemeManager:
|
||||
theme_name = "default"
|
||||
theme_dir = THEMES_PATH / "default"
|
||||
|
||||
default_palette_path = THEMES_PATH / "default" / "palette.json"
|
||||
default_palette = (
|
||||
json.loads(default_palette_path.read_text("utf-8"))
|
||||
if default_palette_path.exists()
|
||||
else {}
|
||||
)
|
||||
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
|
||||
current_loaders = list(self.jinja_env.loader.loaders)
|
||||
if len(current_loaders) > 1:
|
||||
current_loaders[1] = FileSystemLoader(
|
||||
[
|
||||
str(theme_dir / "templates"),
|
||||
str(THEMES_PATH / "default" / "templates"),
|
||||
]
|
||||
if len(current_loaders) > 1 and isinstance(
|
||||
current_loaders[0], PrefixLoader
|
||||
):
|
||||
prefix_loader = current_loaders[0]
|
||||
new_theme_loader = FileSystemLoader(
|
||||
[str(theme_dir), str(THEMES_PATH / "default")]
|
||||
)
|
||||
self.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader])
|
||||
else:
|
||||
self.jinja_env.loader = FileSystemLoader(
|
||||
[str(theme_dir), str(THEMES_PATH / "default")]
|
||||
)
|
||||
self.jinja_env.loader = ChoiceLoader(current_loaders)
|
||||
else:
|
||||
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
|
||||
self.jinja_env.loader = FileSystemLoader(
|
||||
[str(theme_dir), str(THEMES_PATH / "default")]
|
||||
)
|
||||
|
||||
palette_path = theme_dir / "palette.json"
|
||||
palette = (
|
||||
@ -126,42 +306,74 @@ class ThemeManager:
|
||||
"default_assets_dir": THEMES_PATH / "default" / "assets",
|
||||
}
|
||||
self.jinja_env.globals["theme"] = theme_context_dict
|
||||
self.jinja_env.globals["default_theme_palette"] = default_palette
|
||||
logger.info(f"主题管理器已加载主题: {theme_name}")
|
||||
|
||||
async def _resolve_component_template(self, component_path: str) -> str:
|
||||
async def _resolve_component_template(
|
||||
self, component: Renderable, context: "RenderContext"
|
||||
) -> str:
|
||||
"""
|
||||
智能解析组件路径。
|
||||
如果路径是目录,则查找 manifest.json 以获取入口点。
|
||||
智能解析组件模板的路径,支持简单组件和带皮肤(variant)的复杂组件。
|
||||
|
||||
查找顺序如下:
|
||||
1. **带皮肤的组件**: 如果组件定义了 `variant`,则在
|
||||
`components/{component_name}/skins/{variant_name}/` 目录下查找入口文件。
|
||||
2. **标准组件**: 在组件的根目录 `components/{component_name}/` 下查找入口文件。
|
||||
3. **兼容模式**: (作为最终回退)直接查找名为`components/{component_name}.html`
|
||||
的文件
|
||||
|
||||
入口文件名默认为 `main.html`,但可以被组件目录下的 `manifest.json` 文件中的
|
||||
`entrypoint` 字段覆盖。
|
||||
"""
|
||||
if Path(component_path).suffix:
|
||||
return component_path
|
||||
component_path_base = str(component.template_name)
|
||||
|
||||
manifest_path_str = f"{component_path}/manifest.json"
|
||||
variant = getattr(component, "variant", None)
|
||||
cache_key = f"{component_path_base}::{variant or 'default'}"
|
||||
if cached_path := context.resolved_template_paths.get(cache_key):
|
||||
logger.trace(f"模板路径缓存命中: '{cache_key}' -> '{cached_path}'")
|
||||
return cached_path
|
||||
|
||||
if not self.jinja_env.loader:
|
||||
raise TemplateNotFound(
|
||||
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
|
||||
if Path(component_path_base).suffix:
|
||||
try:
|
||||
self.jinja_env.get_template(component_path_base)
|
||||
logger.debug(f"解析到直接模板路径: '{component_path_base}'")
|
||||
return component_path_base
|
||||
except TemplateNotFound as e:
|
||||
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
|
||||
raise e
|
||||
|
||||
entrypoint_filename = "main.html"
|
||||
manifest = await self.get_template_manifest(component_path_base)
|
||||
if manifest and manifest.entrypoint:
|
||||
entrypoint_filename = manifest.entrypoint
|
||||
|
||||
potential_paths = []
|
||||
|
||||
if variant:
|
||||
potential_paths.append(
|
||||
f"{component_path_base}/skins/{variant}/{entrypoint_filename}"
|
||||
)
|
||||
try:
|
||||
_, full_path, _ = self.jinja_env.loader.get_source(
|
||||
self.jinja_env, manifest_path_str
|
||||
)
|
||||
if full_path and Path(full_path).exists():
|
||||
async with aiofiles.open(full_path, encoding="utf-8") as f:
|
||||
manifest_data = json.loads(await f.read())
|
||||
entrypoint = manifest_data.get("entrypoint")
|
||||
if not entrypoint:
|
||||
raise RenderingError(
|
||||
f"组件 '{component_path}' 的 manifest.json 中缺少 "
|
||||
f"'entrypoint' 键。"
|
||||
)
|
||||
return f"{component_path}/{entrypoint}"
|
||||
except TemplateNotFound:
|
||||
logger.debug(
|
||||
f"未找到 '{manifest_path_str}',将回退到默认的 'main.html' 入口点。"
|
||||
)
|
||||
return f"{component_path}/main.html"
|
||||
raise TemplateNotFound(f"无法为组件 '{component_path}' 找到模板入口点。")
|
||||
|
||||
potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
|
||||
|
||||
if entrypoint_filename == "main.html":
|
||||
potential_paths.append(f"{component_path_base}.html")
|
||||
|
||||
for path in potential_paths:
|
||||
try:
|
||||
self.jinja_env.get_template(path)
|
||||
logger.debug(f"解析到模板路径: '{path}'")
|
||||
context.resolved_template_paths[cache_key] = path
|
||||
return path
|
||||
except TemplateNotFound:
|
||||
continue
|
||||
|
||||
err_msg = (
|
||||
f"无法为组件 '{component_path_base}' 找到任何可用的模板。"
|
||||
f"检查路径: {potential_paths}"
|
||||
)
|
||||
logger.error(err_msg)
|
||||
raise TemplateNotFound(err_msg)
|
||||
|
||||
async def get_template_manifest(
|
||||
self, component_path: str
|
||||
@ -186,82 +398,115 @@ class ThemeManager:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
|
||||
async def resolve_markdown_style_path(
|
||||
self, style_name: str, context: "RenderContext"
|
||||
) -> Path | None:
|
||||
"""
|
||||
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
|
||||
[新逻辑] 使用传入的上下文进行缓存。
|
||||
"""
|
||||
if style_name in self._markdown_styles:
|
||||
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
|
||||
return self._markdown_styles[style_name]
|
||||
if cached_path := context.resolved_style_paths.get(style_name):
|
||||
logger.trace(f"Markdown样式路径缓存命中: '{style_name}'")
|
||||
return cached_path
|
||||
|
||||
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
|
||||
return None
|
||||
resolved_path: Path | None = None
|
||||
if registered_path := asset_registry.resolve_markdown_style(style_name):
|
||||
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
|
||||
resolved_path = registered_path
|
||||
|
||||
elif self.current_theme:
|
||||
theme_style_path = (
|
||||
self.current_theme.assets_dir
|
||||
/ "css"
|
||||
/ "styles"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
)
|
||||
if theme_style_path.exists():
|
||||
logger.debug(
|
||||
f"在主题 '{self.current_theme.name}' 中找到"
|
||||
f"Markdown 样式: '{style_name}'"
|
||||
)
|
||||
resolved_path = theme_style_path
|
||||
|
||||
default_style_path = (
|
||||
self.current_theme.default_assets_dir
|
||||
/ "css"
|
||||
/ "styles"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
)
|
||||
if not resolved_path and default_style_path.exists():
|
||||
logger.debug(f"在 'default' 主题中找到 Markdown 样式: '{style_name}'")
|
||||
resolved_path = default_style_path
|
||||
|
||||
if resolved_path:
|
||||
context.resolved_style_paths[style_name] = resolved_path
|
||||
else:
|
||||
logger.warning(
|
||||
f"Markdown 样式 '{style_name}' 在注册表和主题目录中均未找到。"
|
||||
)
|
||||
|
||||
return resolved_path
|
||||
|
||||
async def _render_component_to_html(
|
||||
self,
|
||||
component: Renderable,
|
||||
required_scripts: list[str] | None = None,
|
||||
required_styles: list[str] | None = None,
|
||||
context: "RenderContext",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
|
||||
if not self.current_theme:
|
||||
await self.load_theme()
|
||||
|
||||
component = context.component
|
||||
assert self.current_theme is not None, "主题加载失败"
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
|
||||
custom_style_css = ""
|
||||
if hasattr(component, "get_extra_css"):
|
||||
css_result = component.get_extra_css(self)
|
||||
if inspect.isawaitable(css_result):
|
||||
custom_style_css = await css_result
|
||||
else:
|
||||
custom_style_css = css_result
|
||||
|
||||
def asset_loader(asset_path: str) -> str:
|
||||
"""[新增] 用于在Jinja2模板中解析静态资源的辅助函数。"""
|
||||
assert self.current_theme is not None
|
||||
current_theme_asset = self.current_theme.assets_dir / asset_path
|
||||
if current_theme_asset.exists():
|
||||
return current_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
|
||||
|
||||
default_theme_asset = self.current_theme.default_assets_dir / asset_path
|
||||
if default_theme_asset.exists():
|
||||
return default_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
|
||||
|
||||
logger.warning(
|
||||
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
|
||||
f"{asset_path}"
|
||||
)
|
||||
return ""
|
||||
|
||||
theme_context_dict = model_dump(self.current_theme)
|
||||
theme_context_dict["asset"] = asset_loader
|
||||
|
||||
theme_css_template = self.jinja_env.get_template("theme.css.jinja")
|
||||
theme_css_content = await theme_css_template.render_async(
|
||||
theme=theme_context_dict
|
||||
)
|
||||
|
||||
resolved_template_name = await self._resolve_component_template(
|
||||
str(component.template_name)
|
||||
component, context
|
||||
)
|
||||
logger.debug(
|
||||
f"正在渲染组件 '{component.template_name}' "
|
||||
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
|
||||
"RendererService",
|
||||
"渲染服务",
|
||||
)
|
||||
if self._custom_filters:
|
||||
self.jinja_env.filters.update(self._custom_filters)
|
||||
if self._custom_globals:
|
||||
self.jinja_env.globals.update(self._custom_globals)
|
||||
template = self.jinja_env.get_template(resolved_template_name)
|
||||
|
||||
unpacked_data = {}
|
||||
for key, value in data_dict.items():
|
||||
if key in RESERVED_TEMPLATE_KEYS:
|
||||
logger.warning(
|
||||
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
|
||||
f"在模板 '{component.template_name}' 中请使用 'data.{key}' 访问。"
|
||||
)
|
||||
else:
|
||||
unpacked_data[key] = value
|
||||
|
||||
template_context = {
|
||||
"data": data_dict,
|
||||
"data": component,
|
||||
"theme": theme_context_dict,
|
||||
"theme_css": "",
|
||||
"custom_style_css": custom_style_css,
|
||||
"required_scripts": required_scripts or [],
|
||||
"required_styles": required_styles or [],
|
||||
"frameless": kwargs.get("frameless", False),
|
||||
}
|
||||
template_context.update(unpacked_data)
|
||||
template_context.update(kwargs)
|
||||
|
||||
return await template.render_async(**template_context)
|
||||
html_fragment = await template.render_async(**template_context)
|
||||
|
||||
if not kwargs.get("frameless", False):
|
||||
base_template = self.jinja_env.get_template("partials/_base.html")
|
||||
page_context = {
|
||||
"data": component,
|
||||
"theme_css": theme_css_content,
|
||||
"collected_inline_css": context.collected_inline_css,
|
||||
"required_scripts": list(context.collected_scripts),
|
||||
"collected_asset_styles": list(context.collected_asset_styles),
|
||||
"body_content": html_fragment,
|
||||
}
|
||||
return await base_template.render_async(**page_context)
|
||||
else:
|
||||
return html_fragment
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
|
||||
from . import builders
|
||||
from .builders.core.layout import LayoutBuilder
|
||||
from .models.core.base import RenderableComponent
|
||||
from .models.core.markdown import MarkdownData
|
||||
@ -12,6 +13,14 @@ from .models.core.template import TemplateComponent
|
||||
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
"""
|
||||
创建一个基于独立模板文件的UI组件。
|
||||
适用于不希望遵循标准主题结构,而是直接渲染单个HTML文件的场景。
|
||||
|
||||
参数:
|
||||
path: 指向HTML模板文件的绝对或相对路径。
|
||||
data: 传递给模板的上下文数据字典。
|
||||
|
||||
返回:
|
||||
TemplateComponent: 一个可被 `render()` 函数处理的组件实例。
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
@ -22,15 +31,35 @@ def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
|
||||
"""
|
||||
创建一个基于Markdown内容的UI组件。
|
||||
|
||||
参数:
|
||||
content: 要渲染的Markdown字符串。
|
||||
style: (可选) Markdown的样式名称(如 'github-light')或一个指向
|
||||
自定义CSS文件的路径。
|
||||
|
||||
返回:
|
||||
MarkdownData: 一个可被 `render()` 函数处理的组件实例。
|
||||
"""
|
||||
builder = builders.MarkdownBuilder().text(content)
|
||||
component = builder.build()
|
||||
if isinstance(style, Path):
|
||||
return MarkdownData(markdown=content, css_path=str(style.absolute()))
|
||||
return MarkdownData(markdown=content, style_name=style)
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
component.style_name = style
|
||||
return component
|
||||
|
||||
|
||||
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个垂直布局组件。
|
||||
便捷函数,用于将多个组件垂直堆叠。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的垂直布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.column(**layout_options)
|
||||
for child in children:
|
||||
@ -41,6 +70,14 @@ def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuil
|
||||
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个水平布局组件。
|
||||
便捷函数,用于将多个组件水平排列。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的水平布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.row(**layout_options)
|
||||
for child in children:
|
||||
@ -53,15 +90,25 @@ async def render(
|
||||
data: dict | None = None,
|
||||
*,
|
||||
use_cache: bool = False,
|
||||
debug_mode: Literal["none", "log"] = "none",
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的UI渲染入口。
|
||||
这是第三方开发者最常用的函数,用于将任何可渲染对象转换为图片。
|
||||
|
||||
用法:
|
||||
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
|
||||
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
|
||||
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
|
||||
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
|
||||
|
||||
参数:
|
||||
component_or_path: 一个 `Renderable` 实例,或一个指向模板文件的
|
||||
`str` 或 `Path` 对象。
|
||||
data: (可选) 当 `component_or_path` 是路径时,必须提供此数据字典。
|
||||
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
|
||||
**kwargs: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
@ -73,9 +120,7 @@ async def render(
|
||||
else:
|
||||
component = component_or_path
|
||||
|
||||
return await renderer_service.render(
|
||||
component, use_cache=use_cache, debug_mode=debug_mode, **kwargs
|
||||
)
|
||||
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
async def render_template(
|
||||
@ -94,6 +139,9 @@ async def render_template(
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
return await render(path, data, use_cache=use_cache, **kwargs)
|
||||
|
||||
@ -114,12 +162,16 @@ async def render_markdown(
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
component: MarkdownData
|
||||
builder = builders.MarkdownBuilder().text(md)
|
||||
component = builder.build()
|
||||
if isinstance(style, Path):
|
||||
component = MarkdownData(markdown=md, css_path=str(style.absolute()))
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
component = MarkdownData(markdown=md, style_name=style)
|
||||
component.style_name = style
|
||||
|
||||
return await render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
@ -131,10 +183,44 @@ async def render_full_result(
|
||||
component: Renderable, use_cache: bool = False, **kwargs
|
||||
) -> RenderResult:
|
||||
"""
|
||||
渲染组件并返回包含图片和HTML的完整结果对象,用于调试和高级用途。
|
||||
渲染组件并返回包含图片和HTML的完整结果对象。
|
||||
主要用于调试或需要同时访问图片和其源HTML的场景。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例。
|
||||
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
|
||||
**kwargs: 传递给底层截图引擎的额外参数。
|
||||
|
||||
返回:
|
||||
RenderResult: 一个包含 `image_bytes` 和 `html_content` 的Pydantic模型。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.renderer.service import RenderContext
|
||||
|
||||
return await renderer_service._render_component(
|
||||
component, use_cache=use_cache, **kwargs
|
||||
if not renderer_service._initialized:
|
||||
await renderer_service.initialize()
|
||||
assert renderer_service._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert renderer_service._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=renderer_service,
|
||||
theme_manager=renderer_service._theme_manager,
|
||||
screenshot_engine=renderer_service._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
render_options=kwargs,
|
||||
)
|
||||
return await renderer_service._render_component(context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"builders",
|
||||
"hstack",
|
||||
"markdown",
|
||||
"render",
|
||||
"render_full_result",
|
||||
"render_markdown",
|
||||
"render_template",
|
||||
"template",
|
||||
"vstack",
|
||||
]
|
||||
|
||||
@ -1,19 +1,49 @@
|
||||
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
|
||||
from .charts import EChartsBuilder
|
||||
from .components import (
|
||||
AlertBuilder,
|
||||
AvatarBuilder,
|
||||
AvatarGroupBuilder,
|
||||
BadgeBuilder,
|
||||
DividerBuilder,
|
||||
KpiCardBuilder,
|
||||
ProgressBarBuilder,
|
||||
TimelineBuilder,
|
||||
UserInfoBlockBuilder,
|
||||
)
|
||||
from .core import (
|
||||
CardBuilder,
|
||||
DetailsBuilder,
|
||||
LayoutBuilder,
|
||||
ListBuilder,
|
||||
MarkdownBuilder,
|
||||
NotebookBuilder,
|
||||
TableBuilder,
|
||||
TextBuilder,
|
||||
)
|
||||
from .presets import (
|
||||
PluginHelpPageBuilder,
|
||||
PluginMenuBuilder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InfoCardBuilder",
|
||||
"AlertBuilder",
|
||||
"AvatarBuilder",
|
||||
"AvatarGroupBuilder",
|
||||
"BadgeBuilder",
|
||||
"CardBuilder",
|
||||
"DetailsBuilder",
|
||||
"DividerBuilder",
|
||||
"EChartsBuilder",
|
||||
"KpiCardBuilder",
|
||||
"LayoutBuilder",
|
||||
"ListBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginMenuBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"TableBuilder",
|
||||
"widgets",
|
||||
"TextBuilder",
|
||||
"TimelineBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
|
||||
@ -7,14 +7,24 @@ T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseBuilder(Generic[T_DataModel]):
|
||||
"""所有UI构建器的基类,提供通用的样式化和构建逻辑。"""
|
||||
"""
|
||||
所有UI构建器的通用基类。
|
||||
|
||||
它实现了Builder设计模式,提供了一个流畅的、链式调用的API来创建和配置UI组件的数据模型。
|
||||
同时,它也提供了通用的样式化方法,如 `with_style`, `with_inline_style` 等。
|
||||
|
||||
参数:
|
||||
T_DataModel: 与此构建器关联的 Pydantic 数据模型类型。
|
||||
"""
|
||||
|
||||
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
|
||||
self._inline_style: dict | None = None
|
||||
self._extra_css: str | None = None
|
||||
self._component_css: str | None = None
|
||||
self._variant: str | None = None
|
||||
self._extra_classes: list[str] = []
|
||||
|
||||
@property
|
||||
def data(self) -> T_DataModel:
|
||||
@ -23,6 +33,12 @@ class BaseBuilder(Generic[T_DataModel]):
|
||||
def with_style(self, style_name: str) -> Self:
|
||||
"""
|
||||
为组件应用一个特定的样式。
|
||||
|
||||
参数:
|
||||
style_name: 在主题的CSS中定义的样式类名。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._style_name = style_name
|
||||
return self
|
||||
@ -32,26 +48,70 @@ class BaseBuilder(Generic[T_DataModel]):
|
||||
为组件的根元素应用动态的内联样式。
|
||||
|
||||
参数:
|
||||
style: 一个CSS样式字典,例如 {"background-color":"#fff","font-size":"16px"}
|
||||
style: 一个CSS样式字典,例如
|
||||
`{"background-color":"#fff","font-size":"16px"}`。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._inline_style = style
|
||||
return self
|
||||
|
||||
def with_extra_css(self, css: str) -> Self:
|
||||
def with_variant(self, variant_name: str) -> Self:
|
||||
"""
|
||||
为组件应用一个特定的变体/皮肤。
|
||||
|
||||
参数:
|
||||
variant_name: 在组件的 `skins/` 目录下定义的变体名称。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._variant = variant_name
|
||||
return self
|
||||
|
||||
def with_component_css(self, css: str) -> Self:
|
||||
"""
|
||||
向页面注入一段自定义的CSS样式字符串。
|
||||
|
||||
参数:
|
||||
css: 包含CSS规则的字符串。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._extra_css = css
|
||||
self._component_css = css
|
||||
return self
|
||||
|
||||
def with_classes(self, *class_names: str) -> Self:
|
||||
"""
|
||||
为组件的根元素添加一个或多个CSS工具类。
|
||||
这些类来自主题预定义的工具集。
|
||||
|
||||
示例: .with_classes("p-4", "text-center", "font-bold")
|
||||
"""
|
||||
self._extra_classes.extend(class_names)
|
||||
return self
|
||||
|
||||
def build(self) -> T_DataModel:
|
||||
"""
|
||||
构建并返回配置好的数据模型。
|
||||
这是构建过程的最后一步,它会将所有配置应用到数据模型上。
|
||||
|
||||
返回:
|
||||
T_DataModel: 最终配置好的、可被渲染服务使用的数据模型实例。
|
||||
"""
|
||||
if self._style_name and hasattr(self._data, "style_name"):
|
||||
setattr(self._data, "style_name", self._style_name)
|
||||
|
||||
if self._inline_style and hasattr(self._data, "inline_style"):
|
||||
setattr(self._data, "inline_style", self._inline_style)
|
||||
if self._component_css and hasattr(self._data, "component_css"):
|
||||
setattr(self._data, "component_css", self._component_css)
|
||||
if self._variant and hasattr(self._data, "variant"):
|
||||
setattr(self._data, "variant", self._variant)
|
||||
|
||||
if self._extra_classes and hasattr(self._data, "extra_classes"):
|
||||
setattr(self._data, "extra_classes", self._extra_classes)
|
||||
|
||||
return self._data
|
||||
|
||||
@ -2,87 +2,176 @@ from typing import Any, Generic, Literal, TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
from ..models.charts import (
|
||||
BarChartData,
|
||||
BaseChartData,
|
||||
LineChartData,
|
||||
LineChartSeries,
|
||||
PieChartData,
|
||||
PieChartDataItem,
|
||||
EChartsAxis,
|
||||
EChartsData,
|
||||
EChartsGrid,
|
||||
EChartsSeries,
|
||||
EChartsTitle,
|
||||
EChartsTooltip,
|
||||
)
|
||||
from .base import BaseBuilder
|
||||
|
||||
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
|
||||
|
||||
|
||||
class BaseChartBuilder(BaseBuilder[T_ChartData], Generic[T_ChartData]):
|
||||
"""所有图表构建器的基类"""
|
||||
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
|
||||
"""
|
||||
一个统一的、泛型的 ECharts 图表构建器。
|
||||
提供了设置 ECharts `option` 的核心方法,以及一些常用图表的便利方法。
|
||||
"""
|
||||
|
||||
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=[]
|
||||
def __init__(self, template_name: str, title: str):
|
||||
model = EChartsData(
|
||||
template_path=template_name,
|
||||
title=EChartsTitle(text=title),
|
||||
grid=None,
|
||||
tooltip=None,
|
||||
xAxis=None,
|
||||
yAxis=None,
|
||||
legend=None,
|
||||
background_image=None,
|
||||
)
|
||||
super().__init__(data_model, template_name="components/charts/bar_chart")
|
||||
super().__init__(model, template_name=template_name)
|
||||
|
||||
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]]
|
||||
def set_title(
|
||||
self, text: str, left: Literal["left", "center", "right"] = "center"
|
||||
) -> 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))
|
||||
self._data.title_model = EChartsTitle(text=text, left=left)
|
||||
return self
|
||||
|
||||
def set_background_image(self, background_image: str) -> Self:
|
||||
"""设置背景图片 (仅横向柱状图模板支持)"""
|
||||
self._data.background_image = background_image
|
||||
def set_grid(
|
||||
self,
|
||||
left: str | None = None,
|
||||
right: str | None = None,
|
||||
top: str | None = None,
|
||||
bottom: str | None = None,
|
||||
containLabel: bool = True,
|
||||
) -> Self:
|
||||
self._data.grid_model = EChartsGrid(
|
||||
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
|
||||
)
|
||||
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))
|
||||
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
|
||||
self._data.tooltip_model = EChartsTooltip(trigger=trigger)
|
||||
return self
|
||||
|
||||
def set_x_axis(
|
||||
self,
|
||||
type: Literal["category", "value", "time", "log"],
|
||||
data: list[Any] | None = None,
|
||||
show: bool = True,
|
||||
) -> Self:
|
||||
self._data.x_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
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
|
||||
def set_y_axis(
|
||||
self,
|
||||
type: Literal["category", "value", "time", "log"],
|
||||
data: list[Any] | None = None,
|
||||
show: bool = True,
|
||||
) -> Self:
|
||||
self._data.y_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
return self
|
||||
|
||||
def add_series(
|
||||
self, name: str, data: list[int | float], smooth: bool = False
|
||||
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
|
||||
) -> Self:
|
||||
"""添加一条折线"""
|
||||
self._data.series.append(LineChartSeries(name=name, data=data, smooth=smooth))
|
||||
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
|
||||
self._data.series_models.append(series)
|
||||
return self
|
||||
|
||||
def set_legend(
|
||||
self,
|
||||
data: list[str],
|
||||
orient: Literal["horizontal", "vertical"] = "horizontal",
|
||||
left: str = "auto",
|
||||
) -> Self:
|
||||
self._data.legend_model = {"data": data, "orient": orient, "left": left}
|
||||
return self
|
||||
|
||||
def set_option(self, key: str, value: Any) -> Self:
|
||||
"""
|
||||
[高级] 设置 ECharts `option` 中的一个原始键值对。
|
||||
这会覆盖由其他流畅API方法设置的同名配置。
|
||||
"""
|
||||
self._data.raw_options[key] = value
|
||||
return self
|
||||
|
||||
def set_background_image(self, image_name: str) -> Self:
|
||||
"""【兼容】为横向柱状图设置背景图片。"""
|
||||
self._data.background_image = image_name
|
||||
return self
|
||||
|
||||
|
||||
def bar_chart(
|
||||
title: str,
|
||||
items: list[tuple[str, int | float]],
|
||||
direction: Literal["horizontal", "vertical"] = "horizontal",
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个柱状图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/bar_chart", title)
|
||||
categories = [item[0] for item in items]
|
||||
values = [item[1] for item in items]
|
||||
|
||||
if direction == "horizontal":
|
||||
builder.set_x_axis(type="value")
|
||||
builder.set_y_axis(type="category", data=categories)
|
||||
builder.add_series(
|
||||
type="bar",
|
||||
data=values,
|
||||
)
|
||||
else:
|
||||
builder.set_x_axis(type="category", data=categories)
|
||||
builder.set_y_axis(type="value")
|
||||
builder.add_series(type="bar", data=values)
|
||||
|
||||
return builder
|
||||
|
||||
|
||||
def pie_chart(title: str, items: list[tuple[str, int | float]]) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个饼图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/pie_chart", title)
|
||||
data = [{"name": name, "value": value} for name, value in items]
|
||||
legend_data = [item[0] for item in items]
|
||||
|
||||
builder.set_legend(data=legend_data)
|
||||
builder.add_series(
|
||||
name=title,
|
||||
type="pie",
|
||||
data=data,
|
||||
)
|
||||
return builder
|
||||
|
||||
|
||||
def line_chart(
|
||||
title: str, categories: list[str], series: list[dict[str, Any]]
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个折线图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/line_chart", title)
|
||||
|
||||
builder.set_x_axis(type="category", data=categories)
|
||||
builder.set_y_axis(type="value")
|
||||
for s in series:
|
||||
builder.add_series(
|
||||
type="line",
|
||||
name=s.get("name", ""),
|
||||
data=s.get("data", []),
|
||||
smooth=s.get("smooth", False),
|
||||
)
|
||||
return builder
|
||||
|
||||
|
||||
def radar_chart(
|
||||
title: str, indicators: list[tuple[str, int | float]], series: list[dict[str, Any]]
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个雷达图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/radar_chart", title)
|
||||
legend_data = [s.get("name", "") for s in series]
|
||||
radar_indicators = [{"name": name, "max": max_val} for name, max_val in indicators]
|
||||
|
||||
builder.set_legend(data=legend_data)
|
||||
builder.set_option("radar", {"indicator": radar_indicators})
|
||||
builder.add_series(type="radar", data=series)
|
||||
return builder
|
||||
|
||||
25
zhenxun/ui/builders/components/__init__.py
Normal file
25
zhenxun/ui/builders/components/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""
|
||||
小组件构建器模块
|
||||
包含各种UI小组件的构建器
|
||||
"""
|
||||
|
||||
from .alert import AlertBuilder
|
||||
from .avatar import AvatarBuilder, AvatarGroupBuilder
|
||||
from .badge import BadgeBuilder
|
||||
from .divider import DividerBuilder
|
||||
from .kpi_card import KpiCardBuilder
|
||||
from .progress_bar import ProgressBarBuilder
|
||||
from .timeline import TimelineBuilder
|
||||
from .user_info_block import UserInfoBlockBuilder
|
||||
|
||||
__all__ = [
|
||||
"AlertBuilder",
|
||||
"AvatarBuilder",
|
||||
"AvatarGroupBuilder",
|
||||
"BadgeBuilder",
|
||||
"DividerBuilder",
|
||||
"KpiCardBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"TimelineBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
23
zhenxun/ui/builders/components/alert.py
Normal file
23
zhenxun/ui/builders/components/alert.py
Normal file
@ -0,0 +1,23 @@
|
||||
from typing import Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.alert import Alert
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class AlertBuilder(BaseBuilder[Alert]):
|
||||
"""链式构建提示/标注框组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
type: Literal["info", "success", "warning", "error"] = "info",
|
||||
):
|
||||
data_model = Alert(title=title, content=content, type=type)
|
||||
super().__init__(data_model, template_name="components/widgets/alert")
|
||||
|
||||
def hide_icon(self) -> Self:
|
||||
"""隐藏提示框的默认图标"""
|
||||
self._data.show_icon = False
|
||||
return self
|
||||
38
zhenxun/ui/builders/components/avatar.py
Normal file
38
zhenxun/ui/builders/components/avatar.py
Normal file
@ -0,0 +1,38 @@
|
||||
from typing import Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.avatar import Avatar, AvatarGroup
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class AvatarBuilder(BaseBuilder[Avatar]):
|
||||
"""链式构建单个头像的辅助类"""
|
||||
|
||||
def __init__(self, src: str):
|
||||
data_model = Avatar(src=src, shape="circle", size=50)
|
||||
super().__init__(data_model, template_name="components/widgets/avatar")
|
||||
|
||||
def set_shape(self, shape: Literal["circle", "square"]) -> Self:
|
||||
self._data.shape = shape
|
||||
return self
|
||||
|
||||
def set_size(self, size: int) -> Self:
|
||||
self._data.size = size
|
||||
return self
|
||||
|
||||
|
||||
class AvatarGroupBuilder(BaseBuilder[AvatarGroup]):
|
||||
"""链式构建头像组的辅助类"""
|
||||
|
||||
def __init__(self):
|
||||
data_model = AvatarGroup(avatars=[], spacing=-15, max_count=None)
|
||||
super().__init__(data_model, template_name="components/widgets/avatar_group")
|
||||
|
||||
def add_avatar(self, avatar: Avatar | AvatarBuilder | str) -> Self:
|
||||
if isinstance(avatar, str):
|
||||
self._data.avatars.append(Avatar(src=avatar, shape="circle", size=50))
|
||||
elif isinstance(avatar, AvatarBuilder):
|
||||
self._data.avatars.append(avatar.build())
|
||||
else:
|
||||
self._data.avatars.append(avatar)
|
||||
return self
|
||||
20
zhenxun/ui/builders/components/divider.py
Normal file
20
zhenxun/ui/builders/components/divider.py
Normal file
@ -0,0 +1,20 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.divider import Divider
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class DividerBuilder(BaseBuilder[Divider]):
|
||||
"""链式构建分割线组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
margin: str = "2em 0",
|
||||
color: str = "#f7889c",
|
||||
style: Literal["solid", "dashed", "dotted"] = "solid",
|
||||
thickness: str = "1px",
|
||||
):
|
||||
data_model = Divider(
|
||||
margin=margin, color=color, style=style, thickness=thickness
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/divider")
|
||||
31
zhenxun/ui/builders/components/kpi_card.py
Normal file
31
zhenxun/ui/builders/components/kpi_card.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Any, Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.kpi_card import KpiCard
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class KpiCardBuilder(BaseBuilder[KpiCard]):
|
||||
"""链式构建统计卡片(KPI Card)的辅助类"""
|
||||
|
||||
def __init__(self, label: str, value: Any):
|
||||
data_model = KpiCard(label=label, value=value)
|
||||
super().__init__(data_model, template_name="components/widgets/kpi_card")
|
||||
|
||||
def with_unit(self, unit: str) -> Self:
|
||||
"""设置数值的单位"""
|
||||
self._data.unit = unit
|
||||
return self
|
||||
|
||||
def with_change(
|
||||
self, change: str, type: Literal["positive", "negative", "neutral"] = "neutral"
|
||||
) -> Self:
|
||||
"""设置与上一周期的变化率"""
|
||||
self._data.change = change
|
||||
self._data.change_type = type
|
||||
return self
|
||||
|
||||
def with_icon(self, svg_path: str) -> Self:
|
||||
"""设置卡片图标 (提供SVG path data)"""
|
||||
self._data.icon_svg = svg_path
|
||||
return self
|
||||
28
zhenxun/ui/builders/components/timeline.py
Normal file
28
zhenxun/ui/builders/components/timeline.py
Normal file
@ -0,0 +1,28 @@
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.timeline import Timeline, TimelineItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class TimelineBuilder(BaseBuilder[Timeline]):
|
||||
"""链式构建时间轴组件的辅助类"""
|
||||
|
||||
def __init__(self):
|
||||
data_model = Timeline(items=[])
|
||||
super().__init__(data_model, template_name="components/widgets/timeline")
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
timestamp: str,
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
icon: str | None = None,
|
||||
color: str | None = None,
|
||||
) -> Self:
|
||||
"""向时间轴中添加一个事件点"""
|
||||
item = TimelineItem(
|
||||
timestamp=timestamp, title=title, content=content, icon=icon, color=color
|
||||
)
|
||||
self._data.items.append(item)
|
||||
return self
|
||||
@ -3,14 +3,22 @@
|
||||
包含基础的UI构建器类
|
||||
"""
|
||||
|
||||
from .card import CardBuilder
|
||||
from .details import DetailsBuilder
|
||||
from .layout import LayoutBuilder
|
||||
from .list import ListBuilder
|
||||
from .markdown import MarkdownBuilder
|
||||
from .notebook import NotebookBuilder
|
||||
from .table import TableBuilder
|
||||
from .text import TextBuilder
|
||||
|
||||
__all__ = [
|
||||
"CardBuilder",
|
||||
"DetailsBuilder",
|
||||
"LayoutBuilder",
|
||||
"ListBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"TableBuilder",
|
||||
"TextBuilder",
|
||||
]
|
||||
|
||||
26
zhenxun/ui/builders/core/card.py
Normal file
26
zhenxun/ui/builders/core/card.py
Normal file
@ -0,0 +1,26 @@
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.card import CardData
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class CardBuilder(BaseBuilder[CardData]):
|
||||
"""链式构建通用卡片容器的辅助类"""
|
||||
|
||||
def __init__(self, content: "RenderableComponent | BaseBuilder"):
|
||||
content_model = content.build() if isinstance(content, BaseBuilder) else content
|
||||
data_model = CardData(content=content_model)
|
||||
super().__init__(data_model, template_name="components/core/card")
|
||||
|
||||
def set_header(self, header: "RenderableComponent | BaseBuilder") -> Self:
|
||||
"""设置卡片的头部组件"""
|
||||
header_model = header.build() if isinstance(header, BaseBuilder) else header
|
||||
self._data.header = header_model
|
||||
return self
|
||||
|
||||
def set_footer(self, footer: "RenderableComponent | BaseBuilder") -> Self:
|
||||
"""设置卡片的尾部组件"""
|
||||
footer_model = footer.build() if isinstance(footer, BaseBuilder) else footer
|
||||
self._data.footer = footer_model
|
||||
return self
|
||||
19
zhenxun/ui/builders/core/details.py
Normal file
19
zhenxun/ui/builders/core/details.py
Normal file
@ -0,0 +1,19 @@
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.details import DetailsData, DetailsItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class DetailsBuilder(BaseBuilder[DetailsData]):
|
||||
"""链式构建描述列表(键值对)的辅助类"""
|
||||
|
||||
def __init__(self, title: str | None = None):
|
||||
data_model = DetailsData(title=title, items=[])
|
||||
super().__init__(data_model, template_name="components/core/details")
|
||||
|
||||
def add_item(self, label: str, value: Any) -> Self:
|
||||
"""向列表中添加一个键值对项目"""
|
||||
value_str = str(value)
|
||||
self._data.items.append(DetailsItem(label=label, value=value_str))
|
||||
return self
|
||||
@ -19,16 +19,32 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
self._options: dict[str, Any] = {}
|
||||
|
||||
@classmethod
|
||||
def column(cls, **options: Any) -> Self:
|
||||
def column(
|
||||
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
|
||||
) -> Self:
|
||||
builder = cls()
|
||||
builder._template_name = "layouts/column"
|
||||
builder._template_name = "components/core/layouts/column"
|
||||
builder._options["gap"] = gap
|
||||
builder._options["align_items"] = align_items
|
||||
builder._options.update(options)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def row(cls, **options: Any) -> Self:
|
||||
def row(
|
||||
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
|
||||
) -> Self:
|
||||
builder = cls()
|
||||
builder._template_name = "layouts/row"
|
||||
builder._template_name = "components/core/layouts/row"
|
||||
builder._options["gap"] = gap
|
||||
builder._options["align_items"] = align_items
|
||||
builder._options.update(options)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def grid(cls, columns: int = 2, **options: Any) -> Self:
|
||||
builder = cls()
|
||||
builder._template_name = "components/core/layouts/grid"
|
||||
builder._options["columns"] = columns
|
||||
builder._options.update(options)
|
||||
return builder
|
||||
|
||||
@ -56,15 +72,15 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
向布局中添加一个组件,支持多种组件类型的添加。
|
||||
向布局中添加一个组件项。
|
||||
|
||||
参数:
|
||||
component: 一个 Builder 实例 (如 TableBuilder) 或一个 RenderableComponent
|
||||
数据模型。
|
||||
metadata: (可选) 与此项目关联的元数据,可用于模板。
|
||||
component: 一个 `BaseBuilder` 实例 (如 `TableBuilder()`) 或一个已构建的
|
||||
`RenderableComponent` 数据模型。
|
||||
metadata: (可选) 与此项目关联的元数据,可在布局模板中访问。
|
||||
|
||||
返回:
|
||||
Self: 返回当前布局构建器实例,支持链式调用。
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
component_data = (
|
||||
component.data if isinstance(component, BaseBuilder) else component
|
||||
@ -76,28 +92,24 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
|
||||
def add_option(self, key: str, value: Any) -> Self:
|
||||
"""
|
||||
为布局添加一个自定义选项,该选项会传递给模板。
|
||||
为布局模板添加一个自定义选项。
|
||||
|
||||
例如,`add_option("padding", "30px")` 会在模板的 `data.options`
|
||||
字典中添加 `{"padding": "30px"}`。
|
||||
|
||||
参数:
|
||||
key: 选项的键名,用于在模板中引用。
|
||||
value: 选项的值,可以是任意类型的数据。
|
||||
key: 选项的键名。
|
||||
value: 选项的值。
|
||||
|
||||
返回:
|
||||
Self: 返回当前布局构建器实例,支持链式调用。
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._options[key] = value
|
||||
return self
|
||||
|
||||
def build(self) -> LayoutData:
|
||||
"""
|
||||
[修改] 构建并返回 LayoutData 模型实例。
|
||||
此方法现在是同步的,并且不执行渲染。
|
||||
|
||||
参数:
|
||||
无
|
||||
|
||||
返回:
|
||||
LayoutData: 配置好的布局数据模型。
|
||||
构建并返回 LayoutData 模型实例。
|
||||
"""
|
||||
if not self._template_name:
|
||||
raise ValueError(
|
||||
@ -106,4 +118,4 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
|
||||
self._data.options = self._options
|
||||
self._data.layout_type = self._template_name.split("/")[-1]
|
||||
return self._data
|
||||
return super().build()
|
||||
|
||||
31
zhenxun/ui/builders/core/list.py
Normal file
31
zhenxun/ui/builders/core/list.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.list import ListData, ListItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class ListBuilder(BaseBuilder[ListData]):
|
||||
"""链式构建通用列表的辅助类。"""
|
||||
|
||||
def __init__(self, ordered: bool = False):
|
||||
data_model = ListData(ordered=ordered)
|
||||
super().__init__(data_model, template_name="components/core/list")
|
||||
|
||||
def add_item(self, component: "BaseBuilder | RenderableComponent") -> Self:
|
||||
"""
|
||||
向列表中添加一个项目。
|
||||
|
||||
参数:
|
||||
component: 一个 Builder 实例或一个 RenderableComponent 数据模型。
|
||||
"""
|
||||
component_data = (
|
||||
component.build() if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
self._data.items.append(ListItem(component=component_data))
|
||||
return self
|
||||
|
||||
def ordered(self, is_ordered: bool = True) -> Self:
|
||||
"""设置列表是否为有序列表(带数字编号)。"""
|
||||
self._data.ordered = is_ordered
|
||||
return self
|
||||
@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from ...models.core.markdown import (
|
||||
CodeElement,
|
||||
ComponentElement,
|
||||
HeadingElement,
|
||||
ImageElement,
|
||||
ListElement,
|
||||
@ -12,6 +13,7 @@ from ...models.core.markdown import (
|
||||
MarkdownElement,
|
||||
QuoteElement,
|
||||
RawHtmlElement,
|
||||
RenderableComponent,
|
||||
TableElement,
|
||||
TextElement,
|
||||
)
|
||||
@ -24,7 +26,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
|
||||
"""链式构建Markdown图片的辅助类,支持上下文管理和组合。"""
|
||||
|
||||
def __init__(self):
|
||||
data_model = MarkdownData(markdown="", width=800, css_path=None)
|
||||
data_model = MarkdownData(elements=[], width=800, css_path=None)
|
||||
super().__init__(data_model, template_name="components/core/markdown")
|
||||
self._parts: list[MarkdownElement] = []
|
||||
self._width: int = 800
|
||||
@ -78,6 +80,16 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
|
||||
)
|
||||
return self
|
||||
|
||||
def add_component(
|
||||
self, component: "BaseBuilder | RenderableComponent"
|
||||
) -> "MarkdownBuilder":
|
||||
"""添加一个UI组件(如图表、卡片等)。"""
|
||||
component_data = (
|
||||
component.build() if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
self._append_element(ComponentElement(component=component_data))
|
||||
return self
|
||||
|
||||
def add_builder(self, builder: "MarkdownBuilder") -> "MarkdownBuilder":
|
||||
"""将另一个builder的内容组合进来。"""
|
||||
if self._context_stack:
|
||||
@ -144,8 +156,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
|
||||
"""
|
||||
构建并返回 MarkdownData 模型实例。
|
||||
"""
|
||||
final_markdown = "\n\n".join(part.to_markdown() for part in self._parts).strip()
|
||||
self._data.markdown = final_markdown
|
||||
self._data.elements = self._parts
|
||||
self._data.width = self._width
|
||||
self._data.css_path = self._css_path
|
||||
return super().build()
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.core.table import TableCell, TableData
|
||||
from ..base import BaseBuilder
|
||||
|
||||
@ -12,16 +14,61 @@ class TableBuilder(BaseBuilder[TableData]):
|
||||
super().__init__(data_model, template_name="components/core/table")
|
||||
|
||||
def set_headers(self, headers: list[str]) -> "TableBuilder":
|
||||
"""设置表头"""
|
||||
"""
|
||||
设置表格的表头。
|
||||
|
||||
参数:
|
||||
headers: 一个包含表头文本的字符串列表。
|
||||
|
||||
返回:
|
||||
TableBuilder: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._data.headers = headers
|
||||
return self
|
||||
|
||||
def set_column_alignments(
|
||||
self, alignments: list[Literal["left", "center", "right"]]
|
||||
) -> "TableBuilder":
|
||||
"""
|
||||
设置表格每列的文本对齐方式。
|
||||
|
||||
参数:
|
||||
alignments: 一个包含 'left', 'center', 'right' 的对齐方式列表。
|
||||
|
||||
返回:
|
||||
TableBuilder: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._data.column_alignments = alignments
|
||||
return self
|
||||
|
||||
def set_column_widths(self, widths: list[str | int]) -> "TableBuilder":
|
||||
"""设置每列的宽度"""
|
||||
self._data.column_widths = widths
|
||||
return self
|
||||
|
||||
def add_row(self, row: list[TableCell]) -> "TableBuilder":
|
||||
"""添加单行数据"""
|
||||
"""
|
||||
向表格中添加一行数据。
|
||||
|
||||
参数:
|
||||
row: 一个包含单元格数据的列表。单元格可以是字符串、数字或
|
||||
`TextCell`, `ImageCell` 等模型实例。
|
||||
|
||||
返回:
|
||||
TableBuilder: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._data.rows.append(row)
|
||||
return self
|
||||
|
||||
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
|
||||
"""批量添加多行数据"""
|
||||
"""
|
||||
向表格中批量添加多行数据。
|
||||
|
||||
参数:
|
||||
rows: 一个包含多行数据的列表。
|
||||
|
||||
返回:
|
||||
TableBuilder: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._data.rows.extend(rows)
|
||||
return self
|
||||
|
||||
62
zhenxun/ui/builders/core/text.py
Normal file
62
zhenxun/ui/builders/core/text.py
Normal file
@ -0,0 +1,62 @@
|
||||
from typing import Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.text import TextData, TextSpan
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class TextBuilder(BaseBuilder[TextData]):
|
||||
"""链式构建轻量级富文本组件的辅助类"""
|
||||
|
||||
def __init__(self, text: str = ""):
|
||||
data_model = TextData(spans=[], align="left")
|
||||
super().__init__(data_model, template_name="components/core/text")
|
||||
if text:
|
||||
self.add_span(text)
|
||||
|
||||
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
|
||||
"""设置整个文本块的对齐方式"""
|
||||
self._data.align = align
|
||||
return self
|
||||
|
||||
def add_span(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
bold: bool = False,
|
||||
italic: bool = False,
|
||||
underline: bool = False,
|
||||
strikethrough: bool = False,
|
||||
code: bool = False,
|
||||
color: str | None = None,
|
||||
font_size: str | int | None = None,
|
||||
font_family: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
添加一个带有样式的文本片段。
|
||||
|
||||
参数:
|
||||
text: 文本内容。
|
||||
bold: 是否加粗。
|
||||
italic: 是否斜体。
|
||||
underline: 是否有下划线。
|
||||
strikethrough: 是否有删除线。
|
||||
code: 是否渲染为代码样式。
|
||||
color: 文本颜色 (e.g., '#ff0000', 'red')。
|
||||
font_size: 字体大小 (e.g., 16, '1.2em', '12px')。
|
||||
font_family: 字体族。
|
||||
"""
|
||||
font_size_str = f"{font_size}px" if isinstance(font_size, int) else font_size
|
||||
span = TextSpan(
|
||||
text=text,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
underline=underline,
|
||||
strikethrough=strikethrough,
|
||||
code=code,
|
||||
color=color,
|
||||
font_size=font_size_str,
|
||||
font_family=font_family,
|
||||
)
|
||||
self._data.spans.append(span)
|
||||
return self
|
||||
@ -3,12 +3,10 @@
|
||||
包含预定义的UI组件构建器
|
||||
"""
|
||||
|
||||
from .help_page import PluginHelpPageBuilder
|
||||
from .info_card import InfoCardBuilder
|
||||
from .plugin_help_page import PluginHelpPageBuilder
|
||||
from .plugin_menu import PluginMenuBuilder
|
||||
|
||||
__all__ = [
|
||||
"InfoCardBuilder",
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginMenuBuilder",
|
||||
]
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
@ -1,4 +1,4 @@
|
||||
from ...models.presets.help_page import (
|
||||
from ...models.presets.plugin_help_page import (
|
||||
HelpCategory,
|
||||
PluginHelpPageData,
|
||||
)
|
||||
@ -13,7 +13,7 @@ class PluginHelpPageBuilder(BaseBuilder[PluginHelpPageData]):
|
||||
bot_nickname=bot_nickname, page_title=page_title, categories=[]
|
||||
)
|
||||
|
||||
super().__init__(self._data, template_name="pages/core/help_page")
|
||||
super().__init__(self._data, template_name="pages/core/plugin_help_page")
|
||||
|
||||
def add_category(self, category: HelpCategory) -> "PluginHelpPageBuilder":
|
||||
"""添加一个帮助分类"""
|
||||
@ -1,14 +0,0 @@
|
||||
"""
|
||||
小组件构建器模块
|
||||
包含各种UI小组件的构建器
|
||||
"""
|
||||
|
||||
from .badge import BadgeBuilder
|
||||
from .progress_bar import ProgressBarBuilder
|
||||
from .user_info_block import UserInfoBlockBuilder
|
||||
|
||||
__all__ = [
|
||||
"BadgeBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
@ -1,70 +1,67 @@
|
||||
from .charts import (
|
||||
BarChartData,
|
||||
BaseChartData,
|
||||
LineChartData,
|
||||
LineChartSeries,
|
||||
PieChartData,
|
||||
PieChartDataItem,
|
||||
EChartsData,
|
||||
)
|
||||
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 (
|
||||
from .components import (
|
||||
Badge,
|
||||
Divider,
|
||||
ProgressBar,
|
||||
Rectangle,
|
||||
UserInfoBlock,
|
||||
)
|
||||
from .core import (
|
||||
BaseCell,
|
||||
CodeElement,
|
||||
HeadingElement,
|
||||
ImageCell,
|
||||
ImageElement,
|
||||
LayoutData,
|
||||
LayoutItem,
|
||||
ListElement,
|
||||
ListItemElement,
|
||||
MarkdownData,
|
||||
MarkdownElement,
|
||||
NotebookData,
|
||||
NotebookElement,
|
||||
QuoteElement,
|
||||
RawHtmlElement,
|
||||
TableElement,
|
||||
TextElement,
|
||||
)
|
||||
from .core.notebook import NotebookData, NotebookElement
|
||||
from .core.table import (
|
||||
BaseCell,
|
||||
ImageCell,
|
||||
RenderableComponent,
|
||||
StatusBadgeCell,
|
||||
TableCell,
|
||||
TableData,
|
||||
TableElement,
|
||||
TextCell,
|
||||
TextElement,
|
||||
)
|
||||
from .presets import (
|
||||
HelpCategory,
|
||||
HelpItem,
|
||||
PluginHelpPageData,
|
||||
PluginMenuCategory,
|
||||
PluginMenuData,
|
||||
PluginMenuItem,
|
||||
)
|
||||
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",
|
||||
"EChartsData",
|
||||
"HeadingElement",
|
||||
"HelpCategory",
|
||||
"HelpItem",
|
||||
"ImageCell",
|
||||
"ImageElement",
|
||||
"InfoCardData",
|
||||
"InfoCardMetadataItem",
|
||||
"InfoCardSection",
|
||||
"LayoutData",
|
||||
"LayoutItem",
|
||||
"LineChartData",
|
||||
"LineChartSeries",
|
||||
"ListElement",
|
||||
"ListItemElement",
|
||||
"MarkdownData",
|
||||
"MarkdownElement",
|
||||
"NotebookData",
|
||||
"NotebookElement",
|
||||
"PieChartData",
|
||||
"PieChartDataItem",
|
||||
"PluginHelpPageData",
|
||||
"PluginMenuCategory",
|
||||
"PluginMenuData",
|
||||
|
||||
@ -1,63 +1,122 @@
|
||||
from typing import Literal
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
from .core.base import RenderableComponent
|
||||
|
||||
|
||||
class BaseChartData(RenderableComponent):
|
||||
class EChartsTitle(BaseModel):
|
||||
text: str
|
||||
left: Literal["left", "center", "right"] = "center"
|
||||
|
||||
|
||||
class EChartsAxis(BaseModel):
|
||||
type: Literal["category", "value", "time", "log"]
|
||||
data: list[Any] | None = None
|
||||
show: bool = True
|
||||
|
||||
|
||||
class EChartsSeries(BaseModel):
|
||||
type: str
|
||||
data: list[Any]
|
||||
name: str | None = None
|
||||
label: dict[str, Any] | None = None
|
||||
itemStyle: dict[str, Any] | None = None
|
||||
barMaxWidth: int | None = None
|
||||
smooth: bool | None = None
|
||||
|
||||
|
||||
class EChartsTooltip(BaseModel):
|
||||
trigger: Literal["item", "axis", "none"] = "item"
|
||||
|
||||
|
||||
class EChartsGrid(BaseModel):
|
||||
left: str | None = None
|
||||
right: str | None = None
|
||||
top: str | None = None
|
||||
bottom: str | None = None
|
||||
containLabel: bool = True
|
||||
|
||||
|
||||
class BaseChartData(RenderableComponent, ABC):
|
||||
"""所有图表数据模型的基类"""
|
||||
|
||||
style_name: str | None = None
|
||||
title: str
|
||||
chart_id: str = Field(default_factory=lambda: f"chart-{uuid.uuid4().hex}")
|
||||
|
||||
echarts_options: dict[str, Any] | None = None
|
||||
|
||||
@abstractmethod
|
||||
def build_option(self) -> dict[str, Any]:
|
||||
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_render_data(self) -> dict[str, Any]:
|
||||
"""为图表组件定制渲染数据,动态构建最终的 option 对象。"""
|
||||
dumped_data = model_dump(self, exclude={"template_path"})
|
||||
if hasattr(self, "build_option"):
|
||||
dumped_data["option"] = self.build_option()
|
||||
return dumped_data
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""声明此组件需要 ECharts 库。"""
|
||||
return ["js/echarts.min.js"]
|
||||
|
||||
|
||||
class BarChartData(BaseChartData):
|
||||
"""柱状图(支持横向和竖向)的数据模型"""
|
||||
class EChartsData(BaseChartData):
|
||||
"""统一的 ECharts 图表数据模型"""
|
||||
|
||||
category_data: list[str]
|
||||
data: list[int | float]
|
||||
direction: Literal["horizontal", "vertical"] = "horizontal"
|
||||
background_image: str | None = None
|
||||
template_path: str = Field(..., exclude=True)
|
||||
title_model: EChartsTitle | None = Field(None, alias="title")
|
||||
grid_model: EChartsGrid | None = Field(None, alias="grid")
|
||||
tooltip_model: EChartsTooltip | None = Field(None, alias="tooltip")
|
||||
x_axis_model: EChartsAxis | None = Field(None, alias="xAxis")
|
||||
y_axis_model: EChartsAxis | None = Field(None, alias="yAxis")
|
||||
series_models: list[EChartsSeries] = Field(default_factory=list, alias="series")
|
||||
legend_model: dict[str, Any] | None = Field(default_factory=dict, alias="legend")
|
||||
raw_options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="用于 set_option 的原始覆盖选项"
|
||||
)
|
||||
|
||||
background_image: str | None = Field(
|
||||
None, description="【兼容】用于横向柱状图的背景图片"
|
||||
)
|
||||
|
||||
def build_option(self) -> dict[str, Any]:
|
||||
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
|
||||
option: dict[str, Any] = {}
|
||||
key_map = {
|
||||
"title": "title_model",
|
||||
"grid": "grid_model",
|
||||
"tooltip": "tooltip_model",
|
||||
"xAxis": "x_axis_model",
|
||||
"yAxis": "y_axis_model",
|
||||
"series": "series_models",
|
||||
"legend": "legend_model",
|
||||
}
|
||||
for echarts_key, model_attr in key_map.items():
|
||||
model_instance = getattr(self, model_attr, None)
|
||||
if model_instance:
|
||||
if isinstance(model_instance, list):
|
||||
option[echarts_key] = [
|
||||
model_dump(m, exclude_none=True) for m in model_instance
|
||||
]
|
||||
elif isinstance(model_instance, BaseModel):
|
||||
option[echarts_key] = model_dump(model_instance, exclude_none=True)
|
||||
else:
|
||||
option[echarts_key] = model_instance
|
||||
option.update(self.raw_options)
|
||||
return option
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""为模板提供一个简单的字符串标题,保持向后兼容性。"""
|
||||
return self.title_model.text if self.title_model else ""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/bar_chart"
|
||||
|
||||
|
||||
class PieChartDataItem(BaseModel):
|
||||
name: str
|
||||
value: int | float
|
||||
|
||||
|
||||
class PieChartData(BaseChartData):
|
||||
"""饼图的数据模型"""
|
||||
|
||||
data: list[PieChartDataItem]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/pie_chart"
|
||||
|
||||
|
||||
class LineChartSeries(BaseModel):
|
||||
name: str
|
||||
data: list[int | float]
|
||||
smooth: bool = False
|
||||
|
||||
|
||||
class LineChartData(BaseChartData):
|
||||
"""折线图的数据模型"""
|
||||
|
||||
category_data: list[str]
|
||||
series: list[LineChartSeries]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/charts/line_chart"
|
||||
return self.template_path
|
||||
|
||||
@ -3,15 +3,22 @@
|
||||
包含各种UI组件的数据模型
|
||||
"""
|
||||
|
||||
from .alert import Alert
|
||||
from .badge import Badge
|
||||
from .divider import Divider, Rectangle
|
||||
from .kpi_card import KpiCard
|
||||
from .progress_bar import ProgressBar
|
||||
from .timeline import Timeline, TimelineItem
|
||||
from .user_info_block import UserInfoBlock
|
||||
|
||||
__all__ = [
|
||||
"Alert",
|
||||
"Badge",
|
||||
"Divider",
|
||||
"KpiCard",
|
||||
"ProgressBar",
|
||||
"Rectangle",
|
||||
"Timeline",
|
||||
"TimelineItem",
|
||||
"UserInfoBlock",
|
||||
]
|
||||
|
||||
23
zhenxun/ui/models/components/alert.py
Normal file
23
zhenxun/ui/models/components/alert.py
Normal file
@ -0,0 +1,23 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Alert"]
|
||||
|
||||
|
||||
class Alert(RenderableComponent):
|
||||
"""一个带样式的提示框组件,用于显示重要信息。"""
|
||||
|
||||
component_type: Literal["alert"] = "alert"
|
||||
type: Literal["info", "success", "warning", "error"] = Field(
|
||||
default="info", description="提示框的类型,决定了颜色和图标"
|
||||
)
|
||||
title: str = Field(..., description="提示框的标题")
|
||||
content: str = Field(..., description="提示框的主要内容")
|
||||
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/alert"
|
||||
35
zhenxun/ui/models/components/avatar.py
Normal file
35
zhenxun/ui/models/components/avatar.py
Normal file
@ -0,0 +1,35 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Avatar", "AvatarGroup"]
|
||||
|
||||
|
||||
class Avatar(RenderableComponent):
|
||||
"""单个头像组件。"""
|
||||
|
||||
component_type: Literal["avatar"] = "avatar"
|
||||
src: str = Field(..., description="头像的URL或Base64数据URI")
|
||||
shape: Literal["circle", "square"] = Field("circle", description="头像形状")
|
||||
size: int = Field(50, description="头像尺寸(像素)")
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/avatar"
|
||||
|
||||
|
||||
class AvatarGroup(RenderableComponent):
|
||||
"""一组堆叠的头像组件。"""
|
||||
|
||||
component_type: Literal["avatar_group"] = "avatar_group"
|
||||
avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
|
||||
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
|
||||
max_count: int | None = Field(
|
||||
None, description="最多显示的头像数量,超出部分会显示为'+N'"
|
||||
)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/avatar"
|
||||
29
zhenxun/ui/models/components/kpi_card.py
Normal file
29
zhenxun/ui/models/components/kpi_card.py
Normal file
@ -0,0 +1,29 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["KpiCard"]
|
||||
|
||||
|
||||
class KpiCard(RenderableComponent):
|
||||
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
|
||||
|
||||
component_type: Literal["kpi_card"] = "kpi_card"
|
||||
label: str = Field(..., description="指标的标签或名称")
|
||||
value: Any = Field(..., description="指标的主要数值")
|
||||
unit: str | None = Field(default=None, description="数值的单位,可选")
|
||||
change: str | None = Field(
|
||||
default=None, description="与上一周期的变化,例如 '+15%' 或 '-100'"
|
||||
)
|
||||
change_type: Literal["positive", "negative", "neutral"] = Field(
|
||||
default="neutral", description="变化的类型,用于决定颜色"
|
||||
)
|
||||
icon_svg: str | None = Field(
|
||||
default=None, description="卡片中显示的可选图标 (SVG path data)"
|
||||
)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/kpi_card"
|
||||
30
zhenxun/ui/models/components/timeline.py
Normal file
30
zhenxun/ui/models/components/timeline.py
Normal file
@ -0,0 +1,30 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Timeline", "TimelineItem"]
|
||||
|
||||
|
||||
class TimelineItem(BaseModel):
|
||||
"""时间轴中的单个事件点。"""
|
||||
|
||||
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
|
||||
title: str = Field(..., description="事件的标题")
|
||||
content: str = Field(..., description="事件的详细描述")
|
||||
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
|
||||
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
|
||||
|
||||
|
||||
class Timeline(RenderableComponent):
|
||||
"""一个垂直时间轴组件,用于按顺序展示事件。"""
|
||||
|
||||
component_type: Literal["timeline"] = "timeline"
|
||||
items: list[TimelineItem] = Field(
|
||||
default_factory=list, description="时间轴项目列表"
|
||||
)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/timeline"
|
||||
@ -4,7 +4,10 @@
|
||||
"""
|
||||
|
||||
from .base import RenderableComponent
|
||||
from .card import CardData
|
||||
from .details import DetailsData, DetailsItem
|
||||
from .layout import LayoutData, LayoutItem
|
||||
from .list import ListData, ListItem
|
||||
from .markdown import (
|
||||
CodeElement,
|
||||
HeadingElement,
|
||||
@ -21,16 +24,22 @@ from .markdown import (
|
||||
from .notebook import NotebookData, NotebookElement
|
||||
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell
|
||||
from .template import TemplateComponent
|
||||
from .text import TextData, TextSpan
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"CardData",
|
||||
"CodeElement",
|
||||
"DetailsData",
|
||||
"DetailsItem",
|
||||
"HeadingElement",
|
||||
"ImageCell",
|
||||
"ImageElement",
|
||||
"LayoutData",
|
||||
"LayoutItem",
|
||||
"ListData",
|
||||
"ListElement",
|
||||
"ListItem",
|
||||
"ListItemElement",
|
||||
"MarkdownData",
|
||||
"MarkdownElement",
|
||||
@ -45,5 +54,7 @@ __all__ = [
|
||||
"TableElement",
|
||||
"TemplateComponent",
|
||||
"TextCell",
|
||||
"TextData",
|
||||
"TextElement",
|
||||
"TextSpan",
|
||||
]
|
||||
|
||||
@ -1,20 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Iterator
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from nonebot.compat import model_dump
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
|
||||
|
||||
__all__ = ["ContainerComponent", "RenderableComponent"]
|
||||
|
||||
|
||||
class RenderableComponent(BaseModel, Renderable):
|
||||
"""所有可渲染UI组件的抽象基类。"""
|
||||
"""
|
||||
所有可渲染UI组件的数据模型基类。
|
||||
|
||||
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
|
||||
协议,确保其能够被 `RendererService` 正确处理。
|
||||
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
|
||||
"""
|
||||
|
||||
_is_standalone_template: bool = False
|
||||
inline_style: dict[str, str] | None = None
|
||||
component_css: str | None = None
|
||||
extra_classes: list[str] | None = None
|
||||
variant: str | None = None
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
@ -30,6 +39,10 @@ class RenderableComponent(BaseModel, Renderable):
|
||||
"""[可选] 生命周期钩子,默认无操作。"""
|
||||
pass
|
||||
|
||||
def get_children(self) -> Iterable["RenderableComponent"]:
|
||||
"""默认实现:非容器组件没有子组件。"""
|
||||
return []
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
|
||||
return []
|
||||
@ -40,9 +53,18 @@ class RenderableComponent(BaseModel, Renderable):
|
||||
|
||||
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
|
||||
"""默认实现,返回模型自身的数据字典。"""
|
||||
return model_dump(self)
|
||||
return model_dump(
|
||||
self, exclude={"inline_style", "component_css", "inline_style_str"}
|
||||
)
|
||||
|
||||
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
|
||||
@compat_computed_field
|
||||
def inline_style_str(self) -> str:
|
||||
"""[新增] 一个辅助属性,将内联样式字典转换为CSS字符串"""
|
||||
if not self.inline_style:
|
||||
return ""
|
||||
return "; ".join(f"{k}: {v}" for k, v in self.inline_style.items())
|
||||
|
||||
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
|
||||
return ""
|
||||
|
||||
|
||||
@ -52,37 +74,24 @@ class ContainerComponent(RenderableComponent, ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def _get_renderable_child_items(self) -> Iterator[Any]:
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
"""
|
||||
一个抽象方法,子类必须实现它来返回一个可迭代的对象。
|
||||
迭代器中的每个项目都必须具有 'component' 和 'html_content' 属性。
|
||||
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""
|
||||
通用的 prepare 方法,负责预渲染所有子组件。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的脚本依赖。"""
|
||||
scripts = set(super().get_required_scripts())
|
||||
for child in self.get_children():
|
||||
if child:
|
||||
scripts.update(child.get_required_scripts())
|
||||
return list(scripts)
|
||||
|
||||
child_items = list(self._get_renderable_child_items())
|
||||
if not child_items:
|
||||
return
|
||||
|
||||
components_to_render = [
|
||||
item.component for item in child_items if item.component
|
||||
]
|
||||
|
||||
prepare_tasks = [
|
||||
comp.prepare() for comp in components_to_render if hasattr(comp, "prepare")
|
||||
]
|
||||
if prepare_tasks:
|
||||
await asyncio.gather(*prepare_tasks)
|
||||
|
||||
render_tasks = [
|
||||
renderer_service.render_to_html(comp) for comp in components_to_render
|
||||
]
|
||||
rendered_htmls = await asyncio.gather(*render_tasks)
|
||||
|
||||
for item, html in zip(child_items, rendered_htmls):
|
||||
item.html_content = html
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的样式依赖。"""
|
||||
styles = set(super().get_required_styles())
|
||||
for child in self.get_children():
|
||||
if child:
|
||||
styles.update(child.get_required_styles())
|
||||
return list(styles)
|
||||
|
||||
24
zhenxun/ui/models/core/card.py
Normal file
24
zhenxun/ui/models/core/card.py
Normal file
@ -0,0 +1,24 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
|
||||
class CardData(ContainerComponent):
|
||||
"""通用卡片的数据模型,可以包含头部、内容和尾部"""
|
||||
|
||||
header: RenderableComponent | None = None
|
||||
content: RenderableComponent
|
||||
footer: RenderableComponent | None = None
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/card"
|
||||
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
"""让CSS收集器能够遍历卡片的子组件"""
|
||||
if self.header:
|
||||
yield self.header
|
||||
if self.content:
|
||||
yield self.content
|
||||
if self.footer:
|
||||
yield self.footer
|
||||
23
zhenxun/ui/models/core/details.py
Normal file
23
zhenxun/ui/models/core/details.py
Normal file
@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
|
||||
class DetailsItem(BaseModel):
|
||||
"""描述列表中的单个项目"""
|
||||
|
||||
label: str = Field(..., description="项目的标签/键")
|
||||
value: Any = Field(..., description="项目的值")
|
||||
|
||||
|
||||
class DetailsData(RenderableComponent):
|
||||
"""描述列表(键值对)的数据模型"""
|
||||
|
||||
title: str | None = Field(None, description="列表的可选标题")
|
||||
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/details"
|
||||
@ -1,3 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@ -12,7 +13,6 @@ class LayoutItem(BaseModel):
|
||||
|
||||
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
|
||||
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
|
||||
html_content: str | None = None
|
||||
|
||||
|
||||
class LayoutData(ContainerComponent):
|
||||
@ -27,23 +27,26 @@ class LayoutData(ContainerComponent):
|
||||
default_factory=dict, description="传递给模板的选项"
|
||||
)
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的脚本依赖。"""
|
||||
scripts = set()
|
||||
for item in self.children:
|
||||
scripts.update(item.component.get_required_scripts())
|
||||
return list(scripts)
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[新增] 聚合所有子组件的样式依赖。"""
|
||||
styles = set()
|
||||
for item in self.children:
|
||||
styles.update(item.component.get_required_styles())
|
||||
return list(styles)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return f"layouts/{self.layout_type}"
|
||||
return f"components/core/layouts/{self.layout_type}"
|
||||
|
||||
def _get_renderable_child_items(self):
|
||||
yield from self.children
|
||||
def get_extra_css(self, context: Any) -> str:
|
||||
"""聚合所有子组件的 extra_css。"""
|
||||
all_css = []
|
||||
if self.component_css:
|
||||
all_css.append(self.component_css)
|
||||
|
||||
for item in self.children:
|
||||
if (
|
||||
item.component
|
||||
and hasattr(item.component, "component_css")
|
||||
and item.component.component_css
|
||||
):
|
||||
all_css.append(item.component.component_css)
|
||||
|
||||
return "\n".join(all_css)
|
||||
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
for item in self.children:
|
||||
yield item.component
|
||||
|
||||
30
zhenxun/ui/models/core/list.py
Normal file
30
zhenxun/ui/models/core/list.py
Normal file
@ -0,0 +1,30 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = ["ListData", "ListItem"]
|
||||
|
||||
|
||||
class ListItem(BaseModel):
|
||||
"""列表中的单个项目,其内容可以是任何可渲染组件。"""
|
||||
|
||||
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
|
||||
|
||||
|
||||
class ListData(ContainerComponent):
|
||||
"""通用列表的数据模型,支持有序和无序列表。"""
|
||||
|
||||
component_type: Literal["list"] = "list"
|
||||
items: list[ListItem] = Field(default_factory=list, description="列表项目")
|
||||
ordered: bool = Field(default=False, description="是否为有序列表")
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/list"
|
||||
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
for item in self.items:
|
||||
yield item.component
|
||||
@ -1,16 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiofiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
from .base import RenderableComponent
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"CodeElement",
|
||||
"ComponentElement",
|
||||
"HeadingElement",
|
||||
"ImageElement",
|
||||
"ListElement",
|
||||
@ -32,6 +34,7 @@ class MarkdownElement(BaseModel, ABC):
|
||||
|
||||
|
||||
class TextElement(MarkdownElement):
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
@ -39,6 +42,7 @@ class TextElement(MarkdownElement):
|
||||
|
||||
|
||||
class HeadingElement(MarkdownElement):
|
||||
type: Literal["heading"] = "heading"
|
||||
text: str
|
||||
level: int = Field(..., ge=1, le=6)
|
||||
|
||||
@ -47,6 +51,7 @@ class HeadingElement(MarkdownElement):
|
||||
|
||||
|
||||
class ImageElement(MarkdownElement):
|
||||
type: Literal["image"] = "image"
|
||||
src: str
|
||||
alt: str = "image"
|
||||
|
||||
@ -55,6 +60,7 @@ class ImageElement(MarkdownElement):
|
||||
|
||||
|
||||
class CodeElement(MarkdownElement):
|
||||
type: Literal["code"] = "code"
|
||||
code: str
|
||||
language: str = ""
|
||||
|
||||
@ -63,6 +69,7 @@ class CodeElement(MarkdownElement):
|
||||
|
||||
|
||||
class RawHtmlElement(MarkdownElement):
|
||||
type: Literal["raw_html"] = "raw_html"
|
||||
html: str
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
@ -70,6 +77,7 @@ class RawHtmlElement(MarkdownElement):
|
||||
|
||||
|
||||
class TableElement(MarkdownElement):
|
||||
type: Literal["table"] = "table"
|
||||
headers: list[str]
|
||||
rows: list[list[str]]
|
||||
alignments: list[Literal["left", "center", "right"]] | None = None
|
||||
@ -98,6 +106,8 @@ class ContainerElement(MarkdownElement):
|
||||
|
||||
|
||||
class QuoteElement(ContainerElement):
|
||||
type: Literal["quote"] = "quote"
|
||||
|
||||
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")])
|
||||
@ -109,6 +119,7 @@ class ListItemElement(ContainerElement):
|
||||
|
||||
|
||||
class ListElement(ContainerElement):
|
||||
type: Literal["list"] = "list"
|
||||
ordered: bool = False
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
@ -121,11 +132,21 @@ class ListElement(ContainerElement):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MarkdownData(RenderableComponent):
|
||||
class ComponentElement(MarkdownElement):
|
||||
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
|
||||
|
||||
type: Literal["component"] = "component"
|
||||
component: RenderableComponent
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class MarkdownData(ContainerComponent):
|
||||
"""Markdown转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
markdown: str
|
||||
elements: list[MarkdownElement] = Field(default_factory=list)
|
||||
width: int = 800
|
||||
css_path: str | None = None
|
||||
|
||||
@ -133,7 +154,23 @@ class MarkdownData(RenderableComponent):
|
||||
def template_name(self) -> str:
|
||||
return "components/core/markdown"
|
||||
|
||||
async def get_extra_css(self, theme_manager) -> str:
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
"""让CSS/JS依赖收集器能够递归地找到所有嵌入的组件。"""
|
||||
|
||||
def find_components_recursive(
|
||||
elements: list[MarkdownElement],
|
||||
) -> Iterable[RenderableComponent]:
|
||||
for element in elements:
|
||||
if isinstance(element, ComponentElement):
|
||||
yield element.component
|
||||
if hasattr(element.component, "get_children"):
|
||||
yield from element.component.get_children()
|
||||
elif isinstance(element, ContainerElement):
|
||||
yield from find_components_recursive(element.content)
|
||||
|
||||
yield from find_components_recursive(self.elements)
|
||||
|
||||
async def get_extra_css(self, context: Any) -> str:
|
||||
if self.css_path:
|
||||
css_file = Path(self.css_path)
|
||||
if css_file.is_file():
|
||||
@ -142,14 +179,12 @@ class MarkdownData(RenderableComponent):
|
||||
else:
|
||||
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
|
||||
else:
|
||||
style_name = self.style_name or "github-light"
|
||||
css_path = (
|
||||
theme_manager.current_theme.default_assets_dir
|
||||
/ "css"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
style_name = self.style_name or "light"
|
||||
# 使用上下文对象来解析路径
|
||||
css_path = await context.theme_manager.resolve_markdown_style_path(
|
||||
style_name, context
|
||||
)
|
||||
if css_path.exists():
|
||||
if css_path and css_path.exists():
|
||||
async with aiofiles.open(css_path, encoding="utf-8") as f:
|
||||
return await f.read()
|
||||
return ""
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -29,7 +30,6 @@ class NotebookElement(BaseModel):
|
||||
data: list[str] | None = None
|
||||
ordered: bool | None = None
|
||||
component: RenderableComponent | None = None
|
||||
html_content: str | None = None
|
||||
|
||||
|
||||
class NotebookData(ContainerComponent):
|
||||
@ -42,7 +42,7 @@ class NotebookData(ContainerComponent):
|
||||
def template_name(self) -> str:
|
||||
return "components/core/notebook"
|
||||
|
||||
def _get_renderable_child_items(self):
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
for element in self.elements:
|
||||
if element.type == "component" and element.component:
|
||||
yield element
|
||||
yield element.component
|
||||
|
||||
@ -2,11 +2,13 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...models.components.progress_bar import ProgressBar
|
||||
from .base import RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"ImageCell",
|
||||
"ProgressBarCell",
|
||||
"StatusBadgeCell",
|
||||
"TableCell",
|
||||
"TableData",
|
||||
@ -48,7 +50,15 @@ class StatusBadgeCell(BaseCell):
|
||||
status_type: Literal["ok", "error", "warning", "info"] = "info"
|
||||
|
||||
|
||||
TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None
|
||||
class ProgressBarCell(BaseCell, ProgressBar):
|
||||
"""进度条单元格,继承ProgressBar模型以复用其字段"""
|
||||
|
||||
type: Literal["progress_bar"] = "progress_bar" # type: ignore
|
||||
|
||||
|
||||
TableCell = (
|
||||
TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None
|
||||
)
|
||||
|
||||
|
||||
class TableData(RenderableComponent):
|
||||
@ -59,6 +69,12 @@ class TableData(RenderableComponent):
|
||||
tip: str | None = Field(None, description="表格下方的提示信息")
|
||||
headers: list[str] = Field(default_factory=list, description="表头列表")
|
||||
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
|
||||
column_alignments: list[Literal["left", "center", "right"]] | None = Field(
|
||||
default=None, description="每列的对齐方式"
|
||||
)
|
||||
column_widths: list[str | int] | None = Field(
|
||||
default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])"
|
||||
)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
|
||||
@ -23,3 +23,12 @@ class TemplateComponent(RenderableComponent):
|
||||
def get_render_data(self) -> dict[str, Any]:
|
||||
"""返回传递给模板的数据"""
|
||||
return self.data
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""允许直接访问 `data` 字典中的属性。"""
|
||||
try:
|
||||
return self.data[name]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' 对象没有属性 '{name}'"
|
||||
) from None
|
||||
|
||||
32
zhenxun/ui/models/core/text.py
Normal file
32
zhenxun/ui/models/core/text.py
Normal file
@ -0,0 +1,32 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
|
||||
class TextSpan(BaseModel):
|
||||
"""单个富文本片段的数据模型"""
|
||||
|
||||
text: str
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
underline: bool = False
|
||||
strikethrough: bool = False
|
||||
code: bool = False
|
||||
color: str | None = None
|
||||
font_size: str | None = None
|
||||
font_family: str | None = None
|
||||
|
||||
|
||||
class TextData(RenderableComponent):
|
||||
"""轻量级富文本组件的数据模型"""
|
||||
|
||||
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
|
||||
align: Literal["left", "right", "center"] = Field(
|
||||
"left", description="整体文本对齐方式"
|
||||
)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/text"
|
||||
@ -3,16 +3,12 @@
|
||||
包含预定义的复合组件数据模型
|
||||
"""
|
||||
|
||||
from .card import InfoCardData, InfoCardMetadataItem, InfoCardSection
|
||||
from .help_page import HelpCategory, HelpItem, PluginHelpPageData
|
||||
from .plugin_help_page import HelpCategory, HelpItem, PluginHelpPageData
|
||||
from .plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
|
||||
|
||||
__all__ = [
|
||||
"HelpCategory",
|
||||
"HelpItem",
|
||||
"InfoCardData",
|
||||
"InfoCardMetadataItem",
|
||||
"InfoCardSection",
|
||||
"PluginHelpPageData",
|
||||
"PluginMenuCategory",
|
||||
"PluginMenuData",
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
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:
|
||||
return "components/presets/info_card"
|
||||
@ -35,4 +35,4 @@ class PluginHelpPageData(RenderableComponent):
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "pages/core/help_page"
|
||||
return "pages/core/plugin_help_page"
|
||||
@ -3,12 +3,12 @@ from pathlib import Path
|
||||
import random
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.ui.models import BarChartData
|
||||
from zhenxun.ui.builders import charts as chart_builders
|
||||
|
||||
from .models import Barh
|
||||
|
||||
BACKGROUND_PATH = (
|
||||
Path() / "resources" / "themes" / "default" / "assets" / "bar_chart" / "background"
|
||||
Path() / "resources" / "themes" / "default" / "assets" / "ui" / "background"
|
||||
)
|
||||
|
||||
|
||||
@ -21,12 +21,11 @@ class ChartUtils:
|
||||
if BACKGROUND_PATH.exists()
|
||||
else None
|
||||
)
|
||||
chart_component = BarChartData(
|
||||
title=data.title,
|
||||
category_data=data.category_data,
|
||||
data=data.data,
|
||||
background_image=background_image_name,
|
||||
direction="horizontal",
|
||||
items = list(zip(data.category_data, data.data))
|
||||
builder = chart_builders.bar_chart(
|
||||
title=data.title, items=items, direction="horizontal"
|
||||
)
|
||||
if background_image_name:
|
||||
builder.set_background_image(background_image_name)
|
||||
|
||||
return await ui.render(chart_component)
|
||||
return await ui.render(builder.build())
|
||||
|
||||
@ -18,6 +18,7 @@ __all__ = [
|
||||
"PYDANTIC_V2",
|
||||
"_dump_pydantic_obj",
|
||||
"_is_pydantic_type",
|
||||
"compat_computed_field",
|
||||
"model_copy",
|
||||
"model_dump",
|
||||
"model_json_schema",
|
||||
@ -38,6 +39,12 @@ def model_copy(
|
||||
return model.copy(update=update_dict, deep=deep)
|
||||
|
||||
|
||||
if PYDANTIC_V2:
|
||||
from pydantic import computed_field as compat_computed_field
|
||||
else:
|
||||
compat_computed_field = property
|
||||
|
||||
|
||||
def model_json_schema(model_class: type[BaseModel], **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Pydantic `Model.schema()` (v1) 和 `Model.model_json_schema()` (v2) 的兼容函数。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user