mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-28 16:20:56 +08:00
♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理 (#2094)
* ♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理 - 【重构】重构 `RendererService` 架构,解耦模板引擎、主题管理与截图引擎 - 【重构】重构 `ui` 模块,采用组件注册机制与数据模型驱动,移除旧版 `builders` - 【功能】新增 UI 热重载模式,支持在不重启的情况下实时预览 HTML/CSS 修改 - 【功能】增强启动项资源检查,支持基于 `resources.spec` 的版本校验与自动更新 - 【优化】统一内置插件的 UI 渲染逻辑,迁移至新的 `ui.table`、`ui.markdown` 等工厂接口 - 【优化】优化日志脱敏工具,支持自动折叠调试输出中冗长的样式标签 - 【优化】引入 `AssetResolutionService`,完善皮肤、组件、主题间的多级资源回退机制 - 【优化】新增组件生命周期钩子 `prepare`,支持渲染前的异步数据预处理 * 🚨 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:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bc8e1659ae
commit
5e30694663
+1
-1
@@ -1 +1 @@
|
||||
require_resources_version: ">=1.0.0"
|
||||
require_resources_version: ">=1.1.0"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.drivers import Driver
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
from tortoise import Tortoise
|
||||
from tortoise.exceptions import IntegrityError, OperationalError
|
||||
import ujson as json
|
||||
@@ -85,8 +88,54 @@ from bag_users t1
|
||||
|
||||
@PriorityLifecycle.on_startup(priority=5)
|
||||
async def _():
|
||||
if not ZhenxunRepoManager.check_resources_exists():
|
||||
try:
|
||||
should_update = False
|
||||
resource_path = ZhenxunRepoManager.config.RESOURCE_PATH
|
||||
default_theme_path = resource_path / "themes" / "default"
|
||||
version_file = resource_path / "__version__"
|
||||
|
||||
if (
|
||||
not ZhenxunRepoManager.check_resources_exists()
|
||||
or not default_theme_path.exists()
|
||||
or not version_file.exists()
|
||||
):
|
||||
should_update = True
|
||||
logger.info(
|
||||
"检测到资源文件(字体/主题/版本信息)缺失,准备进行初始化下载...",
|
||||
"资源检查",
|
||||
)
|
||||
else:
|
||||
spec_file = Path("resources.spec")
|
||||
req_ver_str = ">=0.0.0"
|
||||
if spec_file.exists():
|
||||
try:
|
||||
for line in spec_file.read_text("utf-8").splitlines():
|
||||
if line.strip().startswith("require_resources_version:"):
|
||||
req_ver_str = line.split(":", 1)[1].strip().strip("'\"")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
local_ver_str = "0.0.0"
|
||||
try:
|
||||
content = version_file.read_text("utf-8").strip()
|
||||
local_ver_str = (
|
||||
content.split(":", 1)[1].strip() if ":" in content else content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not SpecifierSet(req_ver_str).contains(Version(local_ver_str)):
|
||||
should_update = True
|
||||
logger.info(
|
||||
f"资源版本({local_ver_str})不满足要求({req_ver_str}),准备强制更新...",
|
||||
"资源检查",
|
||||
)
|
||||
|
||||
if should_update:
|
||||
await ZhenxunRepoManager.resources_update()
|
||||
except Exception as e:
|
||||
logger.error(f"资源检查或更新失败: {e}", "资源检查")
|
||||
"""签到与用户的数据迁移"""
|
||||
if goods_list := await GoodsInfo.filter(uuid__isnull=True).all():
|
||||
for goods in goods_list:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast
|
||||
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import (
|
||||
@@ -21,7 +22,6 @@ from zhenxun.models.chat_history import ChatHistory
|
||||
from zhenxun.models.group_member_info import GroupInfoUser
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
from zhenxun.ui.models import ImageCell, TextCell
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
@@ -121,9 +121,12 @@ async def _(
|
||||
if not show_quit_member:
|
||||
fetch_count = count.result * 2
|
||||
|
||||
if rank_data := await ChatHistory.get_group_msg_rank(
|
||||
raw_rank_data = await ChatHistory.get_group_msg_rank(
|
||||
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
|
||||
):
|
||||
)
|
||||
|
||||
if raw_rank_data:
|
||||
rank_data = cast(list[tuple[str, int]], raw_rank_data)
|
||||
rows_data = []
|
||||
platform = "qq"
|
||||
|
||||
@@ -174,10 +177,10 @@ async def _(
|
||||
f"{date_scope[1].replace(microsecond=0)}"
|
||||
)
|
||||
|
||||
builder = TableBuilder(f"消息排行({count.result})", date_str)
|
||||
builder.set_headers(column_name).add_rows(rows_data)
|
||||
table = ui.table(f"消息排行({count.result})", date_str)
|
||||
table.set_headers(column_name).add_rows(rows_data)
|
||||
|
||||
image_bytes = await ui.render(builder.build())
|
||||
image_bytes = await ui.render(table)
|
||||
|
||||
logger.info(
|
||||
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
|
||||
|
||||
@@ -17,11 +17,7 @@ from zhenxun.services import (
|
||||
generate,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.builders import (
|
||||
NotebookBuilder,
|
||||
PluginMenuBuilder,
|
||||
)
|
||||
from zhenxun.ui.models import PluginMenuCategory
|
||||
from zhenxun.ui.models import PluginMenuCategory, PluginMenuData
|
||||
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||
from zhenxun.utils.enum import BlockType, PluginType
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
@@ -109,18 +105,23 @@ async def create_help_img(
|
||||
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
|
||||
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
|
||||
|
||||
builder = PluginMenuBuilder(
|
||||
bot_name=BotConfig.self_nickname,
|
||||
bot_avatar_url=bot_avatar_url,
|
||||
is_detail=is_detail,
|
||||
)
|
||||
|
||||
categories_objects = []
|
||||
for category in categories_for_model:
|
||||
builder.add_category(
|
||||
categories_objects.append(
|
||||
PluginMenuCategory(name=category["name"], items=category["items"])
|
||||
)
|
||||
|
||||
return await ui.render(builder.build())
|
||||
# 直接实例化 Data Model
|
||||
menu_data = PluginMenuData(
|
||||
bot_name=BotConfig.self_nickname,
|
||||
bot_avatar_url=bot_avatar_url,
|
||||
is_detail=is_detail,
|
||||
plugin_count=plugin_count,
|
||||
active_count=active_count,
|
||||
categories=categories_objects,
|
||||
)
|
||||
|
||||
return await ui.render(menu_data)
|
||||
|
||||
|
||||
async def get_user_allow_help(user_id: str) -> list[PluginType]:
|
||||
@@ -299,9 +300,9 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
|
||||
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
|
||||
|
||||
if len(reply_text) > threshold:
|
||||
builder = NotebookBuilder()
|
||||
builder.text(reply_text)
|
||||
return await ui.render(builder.build())
|
||||
notebook = ui.notebook()
|
||||
notebook.text(reply_text)
|
||||
return await ui.render(notebook)
|
||||
|
||||
return reply_text
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from zhenxun import ui
|
||||
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 import StatusBadgeCell, TextCell
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ class Presenters:
|
||||
title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
|
||||
|
||||
if not models:
|
||||
builder = TableBuilder(
|
||||
title=title, tip="当前没有配置任何LLM模型。"
|
||||
).set_headers(["提供商", "模型名称", "API类型", "状态"])
|
||||
return await renderer_service.render(builder.build())
|
||||
table = ui.table(title=title, tip="当前没有配置任何LLM模型。").set_headers(
|
||||
["提供商", "模型名称", "API类型", "状态"]
|
||||
)
|
||||
return await renderer_service.render(table)
|
||||
|
||||
column_name = ["提供商", "模型名称", "API类型", "状态"]
|
||||
rows_data = []
|
||||
@@ -55,13 +55,13 @@ class Presenters:
|
||||
]
|
||||
)
|
||||
|
||||
builder = TableBuilder(
|
||||
table = ui.table(
|
||||
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
|
||||
)
|
||||
builder.set_headers(column_name)
|
||||
builder.set_column_alignments(["left", "left", "left", "center"])
|
||||
builder.add_rows(rows_data)
|
||||
return await renderer_service.render(builder.build(), use_cache=True)
|
||||
table.set_headers(column_name)
|
||||
table.set_column_alignments(["left", "left", "left", "center"])
|
||||
table.add_rows(rows_data)
|
||||
return await renderer_service.render(table, use_cache=True)
|
||||
|
||||
@staticmethod
|
||||
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
|
||||
@@ -82,25 +82,25 @@ class Presenters:
|
||||
if caps.is_embedding_model:
|
||||
cap_list.append("文本嵌入")
|
||||
|
||||
builder = MarkdownBuilder()
|
||||
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
|
||||
builder.text("---")
|
||||
builder.head("提供商信息", 2)
|
||||
builder.text(f"- **名称**: {provider.name}")
|
||||
builder.text(f"- **API 类型**: {provider.api_type}")
|
||||
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
|
||||
md = ui.markdown("")
|
||||
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
|
||||
md.text("---")
|
||||
md.head("提供商信息", 2)
|
||||
md.text(f"- **名称**: {provider.name}")
|
||||
md.text(f"- **API 类型**: {provider.api_type}")
|
||||
md.text(f"- **API Base**: {provider.api_base or '默认'}")
|
||||
|
||||
builder.head("模型详情", 2)
|
||||
md.head("模型详情", 2)
|
||||
|
||||
temp_value = model.temperature or provider.temperature or "未设置"
|
||||
token_value = model.max_tokens or provider.max_tokens or "未设置"
|
||||
|
||||
builder.text(f"- **名称**: {model.model_name}")
|
||||
builder.text(f"- **默认温度**: {temp_value}")
|
||||
builder.text(f"- **最大Token**: {token_value}")
|
||||
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
|
||||
md.text(f"- **名称**: {model.model_name}")
|
||||
md.text(f"- **默认温度**: {temp_value}")
|
||||
md.text(f"- **最大Token**: {token_value}")
|
||||
md.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
|
||||
|
||||
return await renderer_service.render(builder.with_style("light").build())
|
||||
return await renderer_service.render(md.with_style("light"))
|
||||
|
||||
@staticmethod
|
||||
async def format_key_status_as_image(
|
||||
@@ -167,10 +167,8 @@ class Presenters:
|
||||
]
|
||||
)
|
||||
|
||||
builder = TableBuilder(
|
||||
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
|
||||
)
|
||||
builder.set_headers(
|
||||
table = ui.table(title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态")
|
||||
table.set_headers(
|
||||
[
|
||||
"Key (部分)",
|
||||
"状态",
|
||||
@@ -181,5 +179,5 @@ class Presenters:
|
||||
"建议操作",
|
||||
]
|
||||
)
|
||||
builder.add_rows(data_list)
|
||||
return await renderer_service.render(builder.build(), use_cache=False)
|
||||
table.add_rows(data_list)
|
||||
return await renderer_service.render(table, use_cache=False)
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any
|
||||
from zhenxun import ui
|
||||
from zhenxun.models.scheduled_job import ScheduledJob
|
||||
from zhenxun.services import scheduler_manager
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||
from zhenxun.utils.pydantic_compat import model_json_schema
|
||||
|
||||
@@ -159,14 +158,14 @@ async def format_schedule_list_as_image(
|
||||
if not data_list:
|
||||
return "没有找到任何相关的定时任务。"
|
||||
|
||||
builder = TableBuilder(
|
||||
table = ui.table(
|
||||
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
|
||||
)
|
||||
builder.set_headers(
|
||||
table.set_headers(
|
||||
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
|
||||
).add_rows(data_list)
|
||||
return await ui.render(
|
||||
builder.build(),
|
||||
table,
|
||||
viewport={"width": 1400, "height": 10},
|
||||
device_scale_factor=2,
|
||||
)
|
||||
|
||||
@@ -146,11 +146,10 @@ async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes |
|
||||
else:
|
||||
title = "金币全局排行"
|
||||
tip = f"你的排名在全局第 {index} 位哦!"
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(title, tip)
|
||||
builder.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(builder.build())
|
||||
table = ui.table(title, tip)
|
||||
table.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(table)
|
||||
|
||||
|
||||
class ShopManage:
|
||||
@@ -551,11 +550,10 @@ class ShopManage:
|
||||
return None
|
||||
|
||||
column_name = ["-", "使用ID", "名称", "数量", "简介"]
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
|
||||
builder.set_headers(column_name).add_rows(table_rows)
|
||||
return await ui.render(builder.build())
|
||||
table = ui.table(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
|
||||
table.set_headers(column_name).add_rows(table_rows)
|
||||
return await ui.render(table)
|
||||
|
||||
@classmethod
|
||||
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
|
||||
|
||||
@@ -105,11 +105,10 @@ class SignManage:
|
||||
else:
|
||||
title = "好感度全局排行"
|
||||
tip = f"你的排名在全局第 {index} 位哦!"
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(title, tip)
|
||||
builder.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(builder.build())
|
||||
table = ui.table(title, tip)
|
||||
table.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(table)
|
||||
|
||||
@classmethod
|
||||
async def sign(
|
||||
|
||||
@@ -21,12 +21,12 @@ from nonebot_plugin_alconna import (
|
||||
from nonebot_plugin_session import EventSession
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services import group_settings_service, renderer_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.tags import tag_manager
|
||||
from zhenxun.ui import builders as ui
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
@@ -254,15 +254,15 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
|
||||
|
||||
rows.append(row_data)
|
||||
|
||||
builder = ui.TableBuilder(
|
||||
table = ui.table(
|
||||
title=f"插件 '{plugin_name_str}' 全群配置",
|
||||
tip=f"共查询 {len(rows)} 个群组",
|
||||
)
|
||||
builder.set_headers(headers).add_rows(rows)
|
||||
table.set_headers(headers).add_rows(rows)
|
||||
|
||||
viewport_width = 300 + len(config_keys) * 280
|
||||
img = await renderer_service.render(
|
||||
builder.build(), viewport={"width": viewport_width, "height": 10}
|
||||
table, viewport={"width": viewport_width, "height": 10}
|
||||
)
|
||||
await MessageUtils.build_message(img).finish()
|
||||
|
||||
@@ -277,20 +277,20 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
|
||||
f"插件 '{plugin_name_str}' 没有可配置的全局项。"
|
||||
).finish()
|
||||
|
||||
builder = ui.TableBuilder(
|
||||
table = ui.table(
|
||||
title=f"插件 '{plugin_name_str}' 全局可配置项",
|
||||
tip=(
|
||||
f"位于 config.yaml, 使用 pconf set <key>=<value> "
|
||||
f"-p {plugin_name_str} --global 进行设置"
|
||||
),
|
||||
)
|
||||
builder.set_headers(["配置项", "当前值", "类型", "描述"])
|
||||
table.set_headers(["配置项", "当前值", "类型", "描述"])
|
||||
|
||||
for key, config_model in config_group.configs.items():
|
||||
type_name = getattr(
|
||||
config_model.type, "__name__", str(config_model.type)
|
||||
)
|
||||
builder.add_row(
|
||||
table.add_row(
|
||||
[
|
||||
key,
|
||||
truncate_text(str(config_model.value), 20),
|
||||
@@ -299,7 +299,7 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
|
||||
]
|
||||
)
|
||||
|
||||
img = await renderer_service.render(builder.build())
|
||||
img = await renderer_service.render(table)
|
||||
await MessageUtils.build_message(img).finish()
|
||||
else:
|
||||
model = await get_plugin_config_model(plugin_name_str)
|
||||
@@ -309,11 +309,11 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
|
||||
f"插件 '{plugin_name_str}' 不支持分群配置。"
|
||||
).finish()
|
||||
|
||||
builder = ui.TableBuilder(
|
||||
table = ui.table(
|
||||
title=f"插件 '{plugin_name_str}' 可配置项",
|
||||
tip=f"使用 pconf set <key>=<value> -p {plugin_name_str} 进行设置",
|
||||
)
|
||||
builder.set_headers(["配置项", "类型", "描述", "默认值"])
|
||||
table.set_headers(["配置项", "类型", "描述", "默认值"])
|
||||
|
||||
for field in model_fields_list:
|
||||
type_name = getattr(field.annotation, "__name__", str(field.annotation))
|
||||
@@ -323,9 +323,9 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
|
||||
if field.field_info.default is not None
|
||||
else "无"
|
||||
)
|
||||
builder.add_row([field.name, type_name, description, default_value])
|
||||
table.add_row([field.name, type_name, description, default_value])
|
||||
|
||||
img = await renderer_service.render(builder.build())
|
||||
img = await renderer_service.render(table)
|
||||
await MessageUtils.build_message(img).finish()
|
||||
|
||||
else:
|
||||
|
||||
@@ -56,6 +56,15 @@ __plugin_meta__ = PluginMetadata(
|
||||
default_value=False,
|
||||
type=bool,
|
||||
),
|
||||
RegisterConfig(
|
||||
module="UI",
|
||||
key="HOT_RELOAD",
|
||||
value=False,
|
||||
help="是否开启UI热重载模式 (修改HTML/CSS后立即生效,"
|
||||
"性能较低,仅建议开发时开启)",
|
||||
default_value=False,
|
||||
type=bool,
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
@@ -8,8 +8,7 @@ from zhenxun import ui
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.ui.builders import PluginHelpPageBuilder
|
||||
from zhenxun.ui.models import HelpCategory, HelpItem
|
||||
from zhenxun.ui.models import HelpCategory, HelpItem, PluginHelpPageData
|
||||
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||
from zhenxun.utils.enum import PluginType
|
||||
|
||||
@@ -82,12 +81,11 @@ async def create_plugin_help_image(
|
||||
)
|
||||
)
|
||||
|
||||
builder = PluginHelpPageBuilder(
|
||||
bot_nickname=BotConfig.self_nickname, page_title=page_title
|
||||
)
|
||||
# 直接构建 HelpCategory 列表
|
||||
categories = []
|
||||
|
||||
for menu_type, items in grouped_plugins.items():
|
||||
builder.add_category(
|
||||
categories.append(
|
||||
HelpCategory(
|
||||
title=menu_type,
|
||||
icon_svg_path="M12,2L15.09,8.26L22,9.27L17,14.14L18.18,21.02L12,17.77L5.82,21.02L7,14.14L2,9.27L8.91,8.26L12,2Z",
|
||||
@@ -98,7 +96,7 @@ async def create_plugin_help_image(
|
||||
task_category_data = await _get_task_category()
|
||||
if task_category_data["items"]:
|
||||
task_items = [HelpItem(**item) for item in task_category_data["items"]]
|
||||
builder.add_category(
|
||||
categories.append(
|
||||
HelpCategory(
|
||||
title=task_category_data["title"],
|
||||
icon_svg_path=task_category_data["icon_svg_path"],
|
||||
@@ -106,6 +104,13 @@ async def create_plugin_help_image(
|
||||
)
|
||||
)
|
||||
|
||||
image_bytes = await ui.render(builder.build(), use_cache=True)
|
||||
# 直接实例化 Data Model
|
||||
page_data = PluginHelpPageData(
|
||||
bot_nickname=BotConfig.self_nickname,
|
||||
page_title=page_title,
|
||||
categories=categories,
|
||||
)
|
||||
|
||||
image_bytes = await ui.render(page_data, use_cache=True)
|
||||
|
||||
return image_bytes
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||
|
||||
from .service import RendererService
|
||||
from .types import Renderable, RenderResult
|
||||
|
||||
renderer_service = RendererService()
|
||||
|
||||
@@ -11,4 +12,4 @@ async def _init_renderer_service():
|
||||
await renderer_service.initialize()
|
||||
|
||||
|
||||
__all__ = ["renderer_service"]
|
||||
__all__ = ["RenderResult", "Renderable", "renderer_service"]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
渲染器服务的共享配置和常量
|
||||
"""
|
||||
|
||||
RESERVED_TEMPLATE_KEYS: set[str] = {
|
||||
"data",
|
||||
"theme",
|
||||
"theme_css",
|
||||
"extra_css",
|
||||
"required_scripts",
|
||||
"required_styles",
|
||||
"frameless",
|
||||
}
|
||||
@@ -2,10 +2,10 @@ from pathlib import Path
|
||||
|
||||
from nonebot_plugin_htmlrender import html_to_pic
|
||||
|
||||
from .protocols import ScreenshotEngine
|
||||
from .types import BaseScreenshotEngine
|
||||
|
||||
|
||||
class PlaywrightEngine(ScreenshotEngine):
|
||||
class PlaywrightEngine(BaseScreenshotEngine):
|
||||
"""使用 nonebot-plugin-htmlrender 实现的截图引擎。"""
|
||||
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
@@ -26,9 +26,26 @@ class PlaywrightEngine(ScreenshotEngine):
|
||||
)
|
||||
|
||||
|
||||
def get_screenshot_engine() -> ScreenshotEngine:
|
||||
class EngineManager:
|
||||
"""
|
||||
截图引擎工厂函数。
|
||||
目前只返回 PlaywrightEngine, 未来可以根据配置返回不同的引擎。
|
||||
引擎管理器,负责加载和提供具体的截图引擎实例。
|
||||
未来可在此处根据 Config 读取不同的驱动配置。
|
||||
"""
|
||||
return PlaywrightEngine()
|
||||
|
||||
def __init__(self):
|
||||
self._engine_class: type[BaseScreenshotEngine] = PlaywrightEngine
|
||||
self._instance: BaseScreenshotEngine | None = None
|
||||
|
||||
async def get_engine(self) -> BaseScreenshotEngine:
|
||||
if not self._instance:
|
||||
self._instance = self._engine_class()
|
||||
await self._instance.initialize()
|
||||
return self._instance
|
||||
|
||||
async def close(self):
|
||||
if self._instance:
|
||||
await self._instance.close()
|
||||
self._instance = None
|
||||
|
||||
|
||||
engine_manager = EngineManager()
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
"""
|
||||
一个封装了所有主题相关信息的模型。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="主题名称")
|
||||
palette: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
|
||||
)
|
||||
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
|
||||
assets_dir: Path = Field(..., description="主题的资产目录路径")
|
||||
default_assets_dir: Path = Field(
|
||||
..., description="默认主题的资产目录路径,用于资源回退"
|
||||
)
|
||||
|
||||
|
||||
class TemplateManifest(BaseModel):
|
||||
"""
|
||||
模板清单模型,用于描述一个模板的元数据。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="模板的人类可读名称")
|
||||
engine: Literal["html", "markdown"] = Field(
|
||||
"html", description="渲染此模板所需的引擎"
|
||||
)
|
||||
entrypoint: str = Field(
|
||||
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
|
||||
)
|
||||
styles: list[str] | str | None = Field(
|
||||
None,
|
||||
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
|
||||
)
|
||||
render_options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
|
||||
)
|
||||
@@ -1,112 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
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目录)。"""
|
||||
return []
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
|
||||
"""
|
||||
返回一个将传递给模板的数据字典。
|
||||
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
|
||||
|
||||
返回:
|
||||
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
|
||||
"""
|
||||
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
|
||||
可以返回 str 或 awaitable[str]。
|
||||
|
||||
参数:
|
||||
context: 当前的渲染上下文对象,可用于访问主题管理器等。
|
||||
|
||||
返回:
|
||||
str | Awaitable[str]: 注入到页面的额外CSS字符串。
|
||||
"""
|
||||
return ""
|
||||
|
||||
|
||||
class ScreenshotEngine(Protocol):
|
||||
"""
|
||||
一个协议,定义了截图引擎的核心能力。
|
||||
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
|
||||
而无需修改上层渲染服务的代码。
|
||||
"""
|
||||
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
"""
|
||||
将HTML字符串截图为图片。
|
||||
|
||||
参数:
|
||||
html: 要渲染的HTML内容。
|
||||
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
|
||||
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片字节数据。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RenderResult(BaseModel):
|
||||
"""
|
||||
渲染服务的统一返回类型。
|
||||
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
|
||||
"""
|
||||
|
||||
image_bytes: bytes | None = None
|
||||
html_content: str | None = None
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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,53 +1,36 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiofiles
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
TemplateNotFound,
|
||||
select_autoescape,
|
||||
)
|
||||
from jinja2 import TemplateNotFound
|
||||
from nonebot.utils import is_coroutine_callable
|
||||
import ujson as json
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
|
||||
from zhenxun.configs.path_config import UI_CACHE_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.renderer.template import (
|
||||
ComponentRenderStrategy,
|
||||
JinjaTemplateEngine,
|
||||
TemplateFileRenderStrategy,
|
||||
)
|
||||
from zhenxun.services.renderer.theme import DependencyCollector, asset_registry
|
||||
from zhenxun.services.renderer.types import (
|
||||
BaseScreenshotEngine,
|
||||
Renderable,
|
||||
RenderContext,
|
||||
RenderResult,
|
||||
RenderStrategy,
|
||||
)
|
||||
from zhenxun.utils.exception import RenderingError
|
||||
from zhenxun.utils.log_sanitizer import sanitize_for_logging
|
||||
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 .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)
|
||||
from .engine import engine_manager
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
class RendererService:
|
||||
@@ -66,9 +49,9 @@ class RendererService:
|
||||
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
|
||||
|
||||
def __init__(self):
|
||||
self._jinja_env: Environment | None = None
|
||||
self._template_engine: JinjaTemplateEngine | None = None
|
||||
self._theme_manager: ThemeManager | None = None
|
||||
self._screenshot_engine: ScreenshotEngine | None = None
|
||||
self._screenshot_engine: BaseScreenshotEngine | None = None
|
||||
self._initialized = False
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._custom_filters: dict[str, Callable] = {}
|
||||
@@ -77,36 +60,6 @@ class RendererService:
|
||||
self.filter("dump_json")(self._pydantic_tojson_filter)
|
||||
self.global_function("inline_asset")(self._inline_asset_global)
|
||||
|
||||
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模板命名空间。
|
||||
@@ -116,8 +69,11 @@ class RendererService:
|
||||
避免了与核心或其他插件的模板命名冲突。
|
||||
|
||||
参数:
|
||||
namespace: 插件的唯一命名空间,例如插件名。
|
||||
path: 包含该插件模板的目录路径。
|
||||
namespace: 插件的唯一命名空间,建议使用插件模块名
|
||||
path: 包含该插件模板的目录路径
|
||||
|
||||
异常:
|
||||
ValueError: 当提供的路径不是有效目录时抛出
|
||||
"""
|
||||
if namespace in self._plugin_template_paths:
|
||||
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
|
||||
@@ -182,11 +138,11 @@ class RendererService:
|
||||
一个Jinja2全局函数,用于读取并内联一个已注册命名空间下的资源文件内容。
|
||||
主要用于内联SVG,以解决浏览器的跨域安全问题。
|
||||
"""
|
||||
if not self._jinja_env or not self._jinja_env.loader:
|
||||
if not self._template_engine or not self._template_engine.env.loader:
|
||||
return f"<!-- Error: Jinja env not ready for {namespaced_path} -->"
|
||||
try:
|
||||
source, _, _ = self._jinja_env.loader.get_source(
|
||||
self._jinja_env, namespaced_path
|
||||
source, _, _ = self._template_engine.env.loader.get_source(
|
||||
self._template_engine.env, namespaced_path
|
||||
)
|
||||
return source
|
||||
except TemplateNotFound:
|
||||
@@ -205,115 +161,72 @@ class RendererService:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._jinja_env = self._create_jinja_env()
|
||||
try:
|
||||
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
|
||||
self._template_engine = JinjaTemplateEngine(
|
||||
self._plugin_template_paths, auto_reload=hot_reload
|
||||
)
|
||||
|
||||
self._jinja_env.filters.update(self._custom_filters)
|
||||
self._jinja_env.globals.update(self._custom_globals)
|
||||
self._template_engine.env.filters.update(self._custom_filters)
|
||||
self._template_engine.env.globals.update(self._custom_globals)
|
||||
|
||||
self._screenshot_engine = get_screenshot_engine()
|
||||
self._theme_manager = ThemeManager()
|
||||
self._theme_manager.bind_template_engine(self._template_engine.env)
|
||||
|
||||
self._theme_manager = ThemeManager(self._jinja_env)
|
||||
self._template_engine.set_global(
|
||||
"render", self._theme_manager._global_render_component
|
||||
)
|
||||
self._template_engine.set_global(
|
||||
"asset", self._theme_manager.create_asset_loader()
|
||||
)
|
||||
self._template_engine.set_global(
|
||||
"random_asset", self._theme_manager.create_random_asset_loader()
|
||||
)
|
||||
self._template_engine.set_global(
|
||||
"resolve_template", self._theme_manager.resolve_component_template
|
||||
)
|
||||
self._template_engine.set_filter(
|
||||
"md", self._theme_manager._markdown_filter
|
||||
)
|
||||
|
||||
self._screenshot_engine = await engine_manager.get_engine()
|
||||
|
||||
current_theme_name = Config.get_config("UI", "THEME", "default")
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
|
||||
if self._theme_manager.current_theme:
|
||||
self._template_engine.update_theme_loaders(
|
||||
self._theme_manager.current_theme.assets_dir.parent
|
||||
)
|
||||
|
||||
self._template_engine.set_global(
|
||||
"theme", self._theme_manager.current_theme_context
|
||||
)
|
||||
self._template_engine.set_global(
|
||||
"default_theme_palette", self._theme_manager.current_default_palette
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"渲染服务初始化失败,UI功能将不可用: {e}", "RendererService"
|
||||
)
|
||||
|
||||
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)
|
||||
variant = getattr(component, "variant", None)
|
||||
manifest = await context.theme_manager.get_template_manifest(
|
||||
component_path_base, skin=variant
|
||||
)
|
||||
|
||||
style_paths_to_load = []
|
||||
if manifest and manifest.get("styles"):
|
||||
styles = manifest["styles"]
|
||||
styles = [styles] if isinstance(styles, str) else styles
|
||||
|
||||
resolution_base_path = Path(component_path_base)
|
||||
if variant:
|
||||
skin_manifest_path = str(Path(component_path_base) / "skins" / variant)
|
||||
skin_manifest = await context.theme_manager._load_single_manifest(
|
||||
skin_manifest_path
|
||||
)
|
||||
if skin_manifest and "styles" in skin_manifest:
|
||||
resolution_base_path = Path(skin_manifest_path)
|
||||
|
||||
style_paths_to_load.extend(
|
||||
str(resolution_base_path / style).replace("\\", "/") for style in styles
|
||||
)
|
||||
else:
|
||||
base_template_path = (
|
||||
await context.theme_manager._resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
)
|
||||
base_style_path = str(
|
||||
Path(base_template_path).with_name("style.css")
|
||||
).replace("\\", "/")
|
||||
style_paths_to_load.append(base_style_path)
|
||||
|
||||
if variant:
|
||||
skin_style_path = f"{component_path_base}/skins/{variant}/style.css"
|
||||
style_paths_to_load.append(skin_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)
|
||||
await DependencyCollector.collect(component, context)
|
||||
|
||||
async def _render_component(
|
||||
self,
|
||||
context: "RenderContext",
|
||||
) -> RenderResult:
|
||||
"""
|
||||
核心的私有渲染方法,执行完整的渲染流程。
|
||||
|
||||
执行步骤:
|
||||
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
|
||||
并尝试从文件系统中读取缓存图片。
|
||||
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
|
||||
异步数据加载。
|
||||
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
|
||||
收集所有需要的CSS文件、JS文件和内联CSS。
|
||||
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
|
||||
此步骤会处理独立模板和主题内模板两种情况。
|
||||
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
|
||||
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
|
||||
执行完整的组件渲染流程。
|
||||
包含缓存检查、组件生命周期调用、依赖收集、HTML生成、截图以及缓存写入。
|
||||
"""
|
||||
return await self._apply_caching_layer(self._render_component_core, context)
|
||||
|
||||
@@ -329,9 +242,12 @@ class RendererService:
|
||||
cache_path = None
|
||||
component = context.component
|
||||
|
||||
if Config.get_config("UI", "CACHE") and context.use_cache:
|
||||
hot_reload = Config.get_config("UI", "HOT_RELOAD", False)
|
||||
if Config.get_config("UI", "CACHE") and context.use_cache and not hot_reload:
|
||||
try:
|
||||
template_name = component.template_name
|
||||
template_name = str(
|
||||
getattr(component, "template_path", None) or component.template_name
|
||||
)
|
||||
data_dict = component.get_render_data()
|
||||
resolved_data_dict = {}
|
||||
for key, value in data_dict.items():
|
||||
@@ -363,6 +279,7 @@ class RendererService:
|
||||
if (
|
||||
Config.get_config("UI", "CACHE")
|
||||
and context.use_cache
|
||||
and not hot_reload
|
||||
and cache_path
|
||||
and result.image_bytes
|
||||
):
|
||||
@@ -375,154 +292,79 @@ class RendererService:
|
||||
|
||||
return result
|
||||
|
||||
def _select_strategy(self, component: Renderable) -> RenderStrategy:
|
||||
"""
|
||||
根据组件特性选择合适的渲染策略。
|
||||
"""
|
||||
if getattr(component, "_is_standalone_template", False):
|
||||
return TemplateFileRenderStrategy()
|
||||
|
||||
template_path = getattr(component, "template_path", None)
|
||||
if isinstance(template_path, Path) and template_path.is_absolute():
|
||||
return TemplateFileRenderStrategy()
|
||||
|
||||
return ComponentRenderStrategy()
|
||||
|
||||
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
|
||||
"""
|
||||
纯粹的核心渲染逻辑,不包含任何缓存处理。
|
||||
此方法负责从组件数据模型生成最终的图片字节和HTML。
|
||||
不含缓存处理的核心渲染逻辑,负责调度具体的渲染策略。
|
||||
"""
|
||||
component = context.component
|
||||
|
||||
try:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert context.theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
if (
|
||||
hasattr(component, "template_path")
|
||||
and isinstance(
|
||||
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
|
||||
temp_loader = FileSystemLoader(str(template_dir))
|
||||
temp_env = Environment(
|
||||
loader=temp_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
if not self._initialized or not context.screenshot_engine:
|
||||
raise RenderingError(
|
||||
"渲染服务未正确初始化(可能缺少资源文件),无法渲染组件。"
|
||||
)
|
||||
|
||||
temp_env.globals.update(context.theme_manager.jinja_env.globals)
|
||||
temp_env.filters.update(context.theme_manager.jinja_env.filters)
|
||||
temp_env.globals["asset"] = (
|
||||
context.theme_manager._create_standalone_asset_loader(template_dir)
|
||||
)
|
||||
temp_env.filters["md"] = context.theme_manager._markdown_filter
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
template = temp_env.get_template(template_path.name)
|
||||
|
||||
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(context.render_options)
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=template_dir,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
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 = {}
|
||||
variant = getattr(component, "variant", None)
|
||||
if manifest := await context.theme_manager.get_template_manifest(
|
||||
component.template_name, skin=variant
|
||||
):
|
||||
manifest_options = manifest.get("render_options", {})
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(manifest_options)
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
if not context.theme_manager.current_theme:
|
||||
raise RenderingError("渲染失败:主题未被正确加载。")
|
||||
|
||||
html_content = await context.theme_manager._render_component_to_html(
|
||||
context,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
screenshot_options = final_render_options.copy()
|
||||
screenshot_options.pop("extra_css", None)
|
||||
screenshot_options.pop("frameless", None)
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=THEMES_PATH.parent,
|
||||
**screenshot_options,
|
||||
)
|
||||
|
||||
return RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
strategy = self._select_strategy(context.component)
|
||||
return await strategy.render(context)
|
||||
|
||||
except RenderingError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"渲染组件 '{component.__class__.__name__}' 时发生错误",
|
||||
f"渲染组件 '{context.component.__class__.__name__}' 时发生错误",
|
||||
"RendererService",
|
||||
e=e,
|
||||
)
|
||||
raise RenderingError(
|
||||
f"渲染组件 '{component.__class__.__name__}' 失败"
|
||||
f"渲染组件 '{context.component.__class__.__name__}' 失败"
|
||||
) from e
|
||||
|
||||
async def render(
|
||||
self, component: Renderable, use_cache: bool = False, **render_options
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的、多态的渲染入口,直接返回图片字节。
|
||||
将组件渲染为图片字节数据。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
component: 需要渲染的 Renderable 组件实例
|
||||
use_cache: 是否启用渲染缓存,默认为 False
|
||||
**render_options: 传递给底层截图引擎的额外参数,如 `viewport` (字典), `device_scale_factor` 等
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
bytes: 渲染后的PNG图片二进制数据
|
||||
|
||||
异常:
|
||||
RenderingError: 当渲染流程中任何步骤失败时抛出。
|
||||
"""
|
||||
RenderingError: 当渲染流程中任何步骤(初始化、资源缺失、截图失败)发生错误时抛出
|
||||
""" # noqa: E501
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
if (
|
||||
not self._initialized
|
||||
or not self._theme_manager
|
||||
or not self._screenshot_engine
|
||||
or not self._template_engine
|
||||
):
|
||||
raise RenderingError(
|
||||
"渲染服务未正确初始化(可能缺少资源文件),无法生成图片。"
|
||||
)
|
||||
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
template_engine=self._template_engine,
|
||||
screenshot_engine=self._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
@@ -557,20 +399,43 @@ class RendererService:
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
if (
|
||||
not self._initialized
|
||||
or not self._theme_manager
|
||||
or not self._template_engine
|
||||
or not self._screenshot_engine
|
||||
):
|
||||
raise RenderingError("渲染服务未正确初始化(可能缺少资源文件)。")
|
||||
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
template_engine=self._template_engine,
|
||||
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
|
||||
await component.prepare()
|
||||
await DependencyCollector.collect(component, context)
|
||||
|
||||
resolved_template_name = await self._theme_manager.resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
theme_css_template = self._template_engine.env.get_template("theme.css.jinja")
|
||||
theme_css_content = await theme_css_template.render_async(
|
||||
theme=self._theme_manager.current_theme_context
|
||||
)
|
||||
|
||||
return await self._template_engine.render_component_to_html(
|
||||
component,
|
||||
resolved_template_name,
|
||||
self._theme_manager.current_theme_context,
|
||||
theme_css_content,
|
||||
context.collected_inline_css,
|
||||
list(context.collected_scripts),
|
||||
list(context.collected_asset_styles),
|
||||
frameless=frameless,
|
||||
)
|
||||
|
||||
async def reload_theme(self) -> str:
|
||||
@@ -583,12 +448,24 @@ class RendererService:
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
if not self._initialized or not self._theme_manager:
|
||||
raise RenderingError(
|
||||
"渲染服务未正确初始化(可能缺少资源文件),无法重新加载主题。"
|
||||
)
|
||||
|
||||
self._theme_manager._manifest_cache.clear()
|
||||
if self._theme_manager.manifest_registry:
|
||||
self._theme_manager.manifest_registry.clear_cache()
|
||||
logger.debug("已清除UI清单缓存 (manifest cache)。")
|
||||
current_theme_name = Config.get_config("UI", "THEME", "default")
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
|
||||
if self._theme_manager.current_theme and self._template_engine:
|
||||
self._template_engine.update_theme_loaders(
|
||||
self._theme_manager.current_theme.assets_dir.parent
|
||||
)
|
||||
if self._template_engine and self._template_engine.env.cache:
|
||||
self._template_engine.env.cache.clear()
|
||||
|
||||
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
|
||||
return current_theme_name
|
||||
|
||||
@@ -616,6 +493,14 @@ class RendererService:
|
||||
)
|
||||
|
||||
await self._theme_manager.load_theme(theme_name)
|
||||
|
||||
if self._theme_manager.current_theme and self._template_engine:
|
||||
self._template_engine.update_theme_loaders(
|
||||
self._theme_manager.current_theme.assets_dir.parent
|
||||
)
|
||||
if self._template_engine and self._template_engine.env.cache:
|
||||
self._template_engine.env.cache.clear()
|
||||
|
||||
Config.set_config("UI", "THEME", theme_name, auto_save=True)
|
||||
logger.info(f"UI主题已切换为: {theme_name}")
|
||||
return theme_name
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
select_autoescape,
|
||||
)
|
||||
|
||||
from zhenxun.configs.path_config import THEMES_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.renderer.theme import DependencyCollector
|
||||
from zhenxun.services.renderer.types import (
|
||||
RESERVED_TEMPLATE_KEYS,
|
||||
Renderable,
|
||||
RenderResult,
|
||||
RenderStrategy,
|
||||
)
|
||||
from zhenxun.utils.exception import RenderingError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .types import RenderContext
|
||||
|
||||
|
||||
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 JinjaTemplateEngine:
|
||||
"""
|
||||
负责 HTML 生成的核心引擎。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, plugin_template_paths: dict[str, Path], auto_reload: bool = False
|
||||
):
|
||||
self._plugin_template_paths = plugin_template_paths
|
||||
self._auto_reload = auto_reload
|
||||
self.env = self._create_jinja_env()
|
||||
|
||||
def _create_jinja_env(self) -> 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,
|
||||
auto_reload=self._auto_reload,
|
||||
)
|
||||
return env
|
||||
|
||||
def update_theme_loaders(self, theme_dir: Path):
|
||||
"""更新 Loader 以支持多主题"""
|
||||
if self.env.loader and isinstance(self.env.loader, ChoiceLoader):
|
||||
current_loaders = list(self.env.loader.loaders)
|
||||
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.env.loader.loaders = [prefix_loader, new_theme_loader]
|
||||
|
||||
def set_global(self, key: str, value: Any):
|
||||
self.env.globals[key] = value
|
||||
|
||||
def set_filter(self, key: str, func: Callable):
|
||||
self.env.filters[key] = func
|
||||
|
||||
async def render_component_to_html(
|
||||
self,
|
||||
component: Renderable,
|
||||
template_name: str,
|
||||
theme_context: dict,
|
||||
theme_css_content: str,
|
||||
inline_css: list[str],
|
||||
scripts: list[str],
|
||||
styles: list[str],
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
将组件数据和模板结合,生成最终的 HTML 字符串。
|
||||
"""
|
||||
logger.debug(
|
||||
f"正在渲染组件模板: '{template_name}'",
|
||||
"JinjaTemplateEngine",
|
||||
)
|
||||
template = self.env.get_template(template_name)
|
||||
data_dict = component.get_render_data()
|
||||
|
||||
unpacked_data = {}
|
||||
for key, value in data_dict.items():
|
||||
if key in RESERVED_TEMPLATE_KEYS:
|
||||
logger.warning(
|
||||
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
|
||||
f"在模板 '{template_name}' 中请使用 'data.{key}' 访问。"
|
||||
)
|
||||
else:
|
||||
unpacked_data[key] = value
|
||||
|
||||
template_context = {
|
||||
"data": component,
|
||||
"theme": theme_context,
|
||||
"frameless": True,
|
||||
}
|
||||
template_context.update(unpacked_data)
|
||||
template_context.update({k: v for k, v in kwargs.items() if k != "frameless"})
|
||||
|
||||
html_fragment = await template.render_async(**template_context)
|
||||
|
||||
if not kwargs.get("frameless", False):
|
||||
base_template = self.env.get_template("partials/_base.html")
|
||||
page_context = {
|
||||
"data": component,
|
||||
"theme_css": theme_css_content,
|
||||
"collected_inline_css": inline_css,
|
||||
"required_scripts": scripts,
|
||||
"collected_asset_styles": styles,
|
||||
"body_content": html_fragment,
|
||||
}
|
||||
return await base_template.render_async(**page_context)
|
||||
else:
|
||||
style_blocks: list[str] = []
|
||||
if theme_css_content:
|
||||
style_blocks.append(theme_css_content)
|
||||
if inline_css:
|
||||
style_blocks.extend(inline_css)
|
||||
|
||||
if style_blocks:
|
||||
css_content = "\n".join(style_blocks)
|
||||
return f"<style>{css_content}</style>\n{html_fragment}"
|
||||
|
||||
return html_fragment
|
||||
|
||||
|
||||
class ComponentRenderStrategy(RenderStrategy):
|
||||
"""标准组件渲染策略。"""
|
||||
|
||||
async def render(self, context: "RenderContext") -> RenderResult:
|
||||
component = context.component
|
||||
await component.prepare()
|
||||
await DependencyCollector.collect(component, context)
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
_opts = data_dict.get("render_options")
|
||||
component_render_options = _opts if isinstance(_opts, dict) else {}
|
||||
|
||||
component_template_identifier = str(
|
||||
getattr(component, "template_path", None) or component.template_name
|
||||
)
|
||||
variant = getattr(component, "variant", None)
|
||||
manifest_options = {}
|
||||
if manifest := await context.theme_manager.get_template_manifest(
|
||||
component_template_identifier, skin=variant
|
||||
):
|
||||
manifest_options = manifest.render_options
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(manifest_options)
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
if getattr(component, "is_page", False):
|
||||
final_render_options["frameless"] = True
|
||||
|
||||
if not context.theme_manager.current_theme:
|
||||
raise RenderingError("渲染失败:主题未被正确加载。")
|
||||
|
||||
resolved_template_name = await context.theme_manager.resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
theme_css_template = context.template_engine.env.get_template("theme.css.jinja")
|
||||
theme_css_content = await theme_css_template.render_async(
|
||||
theme=context.theme_manager.current_theme_context
|
||||
)
|
||||
|
||||
html_content = await context.template_engine.render_component_to_html(
|
||||
component,
|
||||
resolved_template_name,
|
||||
context.theme_manager.current_theme_context,
|
||||
theme_css_content,
|
||||
context.collected_inline_css,
|
||||
list(context.collected_scripts),
|
||||
list(context.collected_asset_styles),
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
screenshot_options = final_render_options.copy()
|
||||
screenshot_options.pop("extra_css", None)
|
||||
screenshot_options.pop("frameless", None)
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=THEMES_PATH.parent,
|
||||
**screenshot_options,
|
||||
)
|
||||
return RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
|
||||
|
||||
class TemplateFileRenderStrategy(RenderStrategy):
|
||||
"""独立模板文件渲染策略。"""
|
||||
|
||||
async def render(self, context: "RenderContext") -> RenderResult:
|
||||
component = context.component
|
||||
template_path = getattr(component, "template_path")
|
||||
await component.prepare()
|
||||
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
|
||||
|
||||
template_dir = template_path.parent
|
||||
temp_loader = FileSystemLoader(str(template_dir))
|
||||
temp_env = Environment(
|
||||
loader=temp_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
temp_env.globals.update(context.template_engine.env.globals)
|
||||
temp_env.filters.update(context.template_engine.env.filters)
|
||||
temp_env.globals["asset"] = (
|
||||
context.theme_manager._create_standalone_asset_loader(template_dir)
|
||||
)
|
||||
temp_env.globals["random_asset"] = (
|
||||
context.theme_manager.create_random_asset_loader()
|
||||
)
|
||||
temp_env.filters["md"] = context.theme_manager._markdown_filter
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
template = temp_env.get_template(template_path.name)
|
||||
template_context = {
|
||||
"theme": context.theme_manager.current_theme_context,
|
||||
"data": data_dict,
|
||||
}
|
||||
|
||||
for key, value in data_dict.items():
|
||||
if key not in RESERVED_TEMPLATE_KEYS:
|
||||
template_context[key] = value
|
||||
|
||||
html_content = await template.render_async(**template_context)
|
||||
_opts = data_dict.get("render_options")
|
||||
component_render_options = _opts if isinstance(_opts, dict) else {}
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(context.render_options)
|
||||
if getattr(component, "is_page", False):
|
||||
final_render_options["frameless"] = True
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content, base_url_path=template_dir, **final_render_options
|
||||
)
|
||||
return RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
+488
-355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
渲染器服务的统一类型定义文件。
|
||||
合并了原 config.py, models.py, protocols.py。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine import BaseScreenshotEngine
|
||||
from .service import RendererService
|
||||
from .template import JinjaTemplateEngine
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
RESERVED_TEMPLATE_KEYS: set[str] = {
|
||||
"data",
|
||||
"theme",
|
||||
"theme_css",
|
||||
"extra_css",
|
||||
"required_scripts",
|
||||
"required_styles",
|
||||
"frameless",
|
||||
}
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
"""一个封装了所有主题相关信息的模型。"""
|
||||
|
||||
name: str = Field(..., description="主题名称")
|
||||
palette: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
|
||||
)
|
||||
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
|
||||
assets_dir: Path = Field(..., description="主题的资产目录路径")
|
||||
default_assets_dir: Path = Field(
|
||||
..., description="默认主题的资产目录路径,用于资源回退"
|
||||
)
|
||||
|
||||
|
||||
class TemplateManifest(BaseModel):
|
||||
"""模板清单模型,用于描述一个模板的元数据。"""
|
||||
|
||||
name: str | None = Field(None, description="模板的人类可读名称")
|
||||
engine: Literal["html", "markdown"] = Field(
|
||||
"html", description="渲染此模板所需的引擎"
|
||||
)
|
||||
entrypoint: str | None = Field(
|
||||
None, description="模板的入口文件 (例如 'template.html')"
|
||||
)
|
||||
skin: str | None = Field(None, description="默认皮肤")
|
||||
styles: list[str] | str | None = Field(
|
||||
None,
|
||||
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
|
||||
)
|
||||
render_options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
|
||||
)
|
||||
|
||||
|
||||
class RenderResult(BaseModel):
|
||||
"""渲染服务的统一返回类型。"""
|
||||
|
||||
image_bytes: bytes | None = None
|
||||
html_content: str | None = None
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
"""定义可被渲染UI组件必须具备的形态。"""
|
||||
|
||||
component_css: str | None
|
||||
is_page: bool
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def template_name(self) -> str:
|
||||
"""返回用于渲染此组件的Jinja2模板的路径。"""
|
||||
...
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""[可选] 生命周期钩子,用于在渲染前执行异步数据获取和预处理。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
"""返回一个包含所有直接子组件的可迭代对象。"""
|
||||
...
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表。"""
|
||||
return []
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的CSS样式表路径列表。"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
|
||||
"""返回一个将传递给模板的数据字典。"""
|
||||
...
|
||||
|
||||
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
|
||||
"""[可选] 提供额外的CSS。"""
|
||||
return ""
|
||||
|
||||
|
||||
class BaseScreenshotEngine(ABC):
|
||||
"""截图引擎的抽象基类。"""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RenderStrategy(ABC):
|
||||
"""渲染策略接口。"""
|
||||
|
||||
@abstractmethod
|
||||
async def render(self, context: "RenderContext") -> "RenderResult":
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderContext:
|
||||
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
|
||||
|
||||
renderer: "RendererService"
|
||||
theme_manager: "ThemeManager"
|
||||
template_engine: "JinjaTemplateEngine"
|
||||
screenshot_engine: "BaseScreenshotEngine"
|
||||
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)
|
||||
+278
-56
@@ -1,26 +1,61 @@
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import builders
|
||||
from .builders.core.layout import LayoutBuilder
|
||||
from .models.core.base import RenderableComponent
|
||||
from .models.core.markdown import MarkdownData
|
||||
from .models.core.template import TemplateComponent
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.renderer.types import Renderable, RenderResult
|
||||
|
||||
from .models.components import (
|
||||
Alert,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Divider,
|
||||
KpiCard,
|
||||
ProgressBar,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
UserInfoBlock,
|
||||
)
|
||||
from .models.core import (
|
||||
CardData,
|
||||
DetailsData,
|
||||
LayoutData,
|
||||
ListData,
|
||||
MarkdownData,
|
||||
NotebookData,
|
||||
RenderableComponent,
|
||||
TableData,
|
||||
TemplateComponent,
|
||||
TextData,
|
||||
)
|
||||
from .registry import component, create
|
||||
|
||||
T_Model = TypeVar("T_Model", bound=BaseModel)
|
||||
|
||||
|
||||
def register_component(namespace: str, template_dir: Path):
|
||||
"""
|
||||
注册一个第三方组件包(命名空间)。
|
||||
注册后,可在模板中通过 `@namespace/template.html` 引用。
|
||||
"""
|
||||
|
||||
renderer_service.register_template_namespace(namespace, template_dir)
|
||||
|
||||
|
||||
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
"""
|
||||
创建一个基于独立模板文件的UI组件。
|
||||
适用于不希望遵循标准主题结构,而是直接渲染单个HTML文件的场景。
|
||||
适用于不遵循标准主题结构,直接渲染单个HTML文件的场景。
|
||||
|
||||
参数:
|
||||
path: 指向HTML模板文件的绝对或相对路径。
|
||||
data: 传递给模板的上下文数据字典。
|
||||
path: 指向HTML模板文件的绝对或相对路径
|
||||
data: 传递给模板的上下文数据字典
|
||||
|
||||
返回:
|
||||
TemplateComponent: 一个可被 `render()` 函数处理的组件实例。
|
||||
TemplateComponent: 可被 `render()` 函数处理的组件实例
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
@@ -28,7 +63,7 @@ def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
return TemplateComponent(template_path=path, data=data)
|
||||
|
||||
|
||||
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
|
||||
def markdown(content: str = "", style: str | Path | None = "default") -> MarkdownData:
|
||||
"""
|
||||
创建一个基于Markdown内容的UI组件。
|
||||
|
||||
@@ -38,10 +73,12 @@ def markdown(content: str, style: str | Path | None = "default") -> MarkdownData
|
||||
自定义CSS文件的路径。
|
||||
|
||||
返回:
|
||||
MarkdownData: 一个可被 `render()` 函数处理的组件实例。
|
||||
MarkdownData: Markdown 组件实例
|
||||
"""
|
||||
builder = builders.MarkdownBuilder().text(content)
|
||||
component = builder.build()
|
||||
component = MarkdownData()
|
||||
if content:
|
||||
component.text(content)
|
||||
|
||||
if isinstance(style, Path):
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
@@ -49,47 +86,195 @@ def markdown(content: str, style: str | Path | None = "default") -> MarkdownData
|
||||
return component
|
||||
|
||||
|
||||
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
def table(title: str, tip: str | None = None) -> TableData:
|
||||
"""
|
||||
创建一个表格组件构建器。
|
||||
支持链式调用,例如: `ui.table("Title").set_headers(["A", "B"]).add_row([1, 2])`
|
||||
|
||||
参数:
|
||||
title: 表格标题
|
||||
tip: 标题旁的提示文本(可选)
|
||||
"""
|
||||
return TableData(title=title, tip=tip, headers=[], rows=[])
|
||||
|
||||
|
||||
def notebook(data: list[Any] | None = None) -> NotebookData:
|
||||
"""
|
||||
创建一个 Notebook 文档构建器。
|
||||
|
||||
参数:
|
||||
data: 初始元素列表(可选)
|
||||
"""
|
||||
return NotebookData(elements=data or []) # type: ignore
|
||||
|
||||
|
||||
def alert(
|
||||
title: str,
|
||||
content: str,
|
||||
type: Literal["info", "success", "warning", "error"] = "info",
|
||||
) -> Alert:
|
||||
"""创建 Alert 组件"""
|
||||
return Alert(
|
||||
title=title,
|
||||
content=content,
|
||||
type=type,
|
||||
)
|
||||
|
||||
|
||||
def badge(
|
||||
text: str,
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = "info",
|
||||
) -> Badge:
|
||||
"""创建 Badge 组件"""
|
||||
return Badge(
|
||||
text=text,
|
||||
color_scheme=color_scheme,
|
||||
)
|
||||
|
||||
|
||||
def divider(
|
||||
margin: str = "2em 0",
|
||||
color: str = "#f7889c",
|
||||
style: Literal["solid", "dashed", "dotted"] = "solid",
|
||||
thickness: str = "1px",
|
||||
) -> Divider:
|
||||
"""创建 Divider 组件"""
|
||||
return Divider(
|
||||
margin=margin,
|
||||
color=color,
|
||||
style=style,
|
||||
thickness=thickness,
|
||||
)
|
||||
|
||||
|
||||
def progress_bar(
|
||||
progress: float,
|
||||
label: str | None = None,
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = "primary",
|
||||
animated: bool = False,
|
||||
) -> ProgressBar:
|
||||
"""
|
||||
创建 ProgressBar 进度条组件。
|
||||
支持多种预设颜色方案和动画效果。
|
||||
"""
|
||||
return ProgressBar(
|
||||
progress=progress,
|
||||
label=label,
|
||||
color_scheme=color_scheme,
|
||||
animated=animated,
|
||||
)
|
||||
|
||||
|
||||
def kpi_card(label: str, value: Any, **kwargs) -> KpiCard:
|
||||
"""创建 KPI 卡片"""
|
||||
return KpiCard(label=label, value=value, **kwargs)
|
||||
|
||||
|
||||
def text(
|
||||
text: str,
|
||||
align: Literal["left", "right", "center"] = "left",
|
||||
**kwargs,
|
||||
) -> TextData:
|
||||
"""
|
||||
创建纯文本组件。
|
||||
|
||||
参数:
|
||||
text: 文本内容
|
||||
align: 对齐方式
|
||||
**kwargs: 传递给首个文本片段的样式参数 (如 bold=True, color='red')
|
||||
|
||||
返回:
|
||||
TextData: 支持链式调用 .add_span() 的文本模型
|
||||
"""
|
||||
model = TextData(align=align)
|
||||
if text:
|
||||
model.add_span(text, **kwargs)
|
||||
return model
|
||||
|
||||
|
||||
def card(content: RenderableComponent) -> CardData:
|
||||
"""创建 Card 组件"""
|
||||
return CardData(content=content)
|
||||
|
||||
|
||||
def avatar(
|
||||
src: str, shape: Literal["circle", "square"] = "circle", size: int = 50
|
||||
) -> Avatar:
|
||||
return Avatar(src=src, shape=shape, size=size)
|
||||
|
||||
|
||||
def avatar_group(
|
||||
avatars: list[Avatar | str] | None = None,
|
||||
spacing: int = -15,
|
||||
max_count: int | None = None,
|
||||
) -> AvatarGroup:
|
||||
"""
|
||||
创建头像组。
|
||||
avatars: 头像对象或URL列表。
|
||||
"""
|
||||
avatar_objs = []
|
||||
if avatars:
|
||||
for item in avatars:
|
||||
if isinstance(item, str):
|
||||
avatar_objs.append(Avatar(src=item)) # type: ignore
|
||||
else:
|
||||
avatar_objs.append(item)
|
||||
return AvatarGroup(avatars=avatar_objs, spacing=spacing, max_count=max_count)
|
||||
|
||||
|
||||
def timeline(items: list[dict[str, Any]] | None = None) -> Timeline:
|
||||
"""
|
||||
创建时间轴。
|
||||
items: 包含 timestamp, title, content, icon, color 的字典列表。
|
||||
"""
|
||||
timeline_items = []
|
||||
if items:
|
||||
for item in items:
|
||||
timeline_items.append(TimelineItem(**item))
|
||||
return Timeline(items=timeline_items)
|
||||
|
||||
|
||||
def user_info_block(
|
||||
name: str,
|
||||
avatar_url: str,
|
||||
subtitle: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> UserInfoBlock:
|
||||
return UserInfoBlock(
|
||||
name=name,
|
||||
avatar_url=avatar_url,
|
||||
subtitle=subtitle,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
|
||||
def vstack(children: Sequence[Any], **layout_options) -> LayoutData:
|
||||
"""
|
||||
创建一个垂直布局组件。
|
||||
便捷函数,用于将多个组件垂直堆叠。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的垂直布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.column(**layout_options)
|
||||
layout = LayoutData.column(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
layout.add_item(child)
|
||||
return layout
|
||||
|
||||
|
||||
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
def hstack(children: Sequence[Any], **layout_options) -> LayoutData:
|
||||
"""
|
||||
创建一个水平布局组件。
|
||||
便捷函数,用于将多个组件水平排列。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的水平布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.row(**layout_options)
|
||||
layout = LayoutData.row(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
layout.add_item(child)
|
||||
return layout
|
||||
|
||||
|
||||
async def render(
|
||||
component_or_path: Renderable | str | Path,
|
||||
data: dict | None = None,
|
||||
*,
|
||||
template: str | Path | None = None,
|
||||
use_cache: bool = False,
|
||||
is_page: bool = False,
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
"""
|
||||
@@ -99,32 +284,51 @@ async def render(
|
||||
用法:
|
||||
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
|
||||
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
|
||||
3. 动态替换模板: `render(my_data, template="plugins/my_plugin/custom_card")`
|
||||
|
||||
参数:
|
||||
component_or_path: 一个 `Renderable` 实例,或一个指向模板文件的
|
||||
`str` 或 `Path` 对象。
|
||||
data: (可选) 当 `component_or_path` 是路径时,必须提供此数据字典。
|
||||
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
|
||||
**kwargs: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
`str` 或 `Path` 对象
|
||||
data: 当 `component_or_path` 是路径时,必须提供此数据字典
|
||||
template: 强制使用的模板路径,覆盖组件默认设置(可选)
|
||||
use_cache: 是否启用渲染结果缓存(默认为 False)
|
||||
is_page: 标记此次渲染是否为完整页面(自带html/body),默认为 False
|
||||
**kwargs: 传递给截图引擎的参数,如 `viewport={"width": 800, "height": 600}`
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
bytes: PNG图片二进制数据
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
component: Renderable
|
||||
if isinstance(component_or_path, str | Path):
|
||||
if data is None:
|
||||
raise ValueError("使用模板路径渲染时必须提供 'data' 参数。")
|
||||
component = TemplateComponent(template_path=component_or_path, data=data)
|
||||
component = TemplateComponent(
|
||||
template_path=component_or_path, data=data, is_page=is_page
|
||||
)
|
||||
else:
|
||||
component = component_or_path
|
||||
|
||||
if template:
|
||||
if isinstance(template, Path):
|
||||
template_str = template.as_posix()
|
||||
else:
|
||||
template_str = str(template).replace("\\", "/")
|
||||
if hasattr(component, "template_path"):
|
||||
setattr(component, "template_path", template_str)
|
||||
|
||||
if is_page and hasattr(component, "is_page"):
|
||||
component.is_page = True
|
||||
|
||||
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
async def render_template(
|
||||
path: str | Path, data: dict, use_cache: bool = False, **kwargs
|
||||
path: str | Path,
|
||||
data: dict,
|
||||
use_cache: bool = False,
|
||||
*,
|
||||
is_page: bool = True,
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
"""
|
||||
渲染一个独立的Jinja2模板文件。
|
||||
@@ -135,6 +339,8 @@ async def render_template(
|
||||
path: 模板文件路径,相对于主题模板目录。
|
||||
data: 传递给模板的数据字典。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
is_page: (可选) 标记该模板是否为完整页面。默认为 True,因为独立模板通常已
|
||||
包含文档骨架。
|
||||
**kwargs: 传递给渲染服务的额外参数。
|
||||
|
||||
返回:
|
||||
@@ -143,7 +349,7 @@ async def render_template(
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
return await render(path, data, use_cache=use_cache, **kwargs)
|
||||
return await render(path, data, use_cache=use_cache, is_page=is_page, **kwargs)
|
||||
|
||||
|
||||
async def render_markdown(
|
||||
@@ -166,8 +372,9 @@ async def render_markdown(
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
builder = builders.MarkdownBuilder().text(md)
|
||||
component = builder.build()
|
||||
component = MarkdownData()
|
||||
component.text(md)
|
||||
|
||||
if isinstance(style, Path):
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
@@ -176,9 +383,6 @@ async def render_markdown(
|
||||
return await render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
from zhenxun.services.renderer.protocols import RenderResult
|
||||
|
||||
|
||||
async def render_full_result(
|
||||
component: Renderable, use_cache: bool = False, **kwargs
|
||||
) -> RenderResult:
|
||||
@@ -194,17 +398,18 @@ async def render_full_result(
|
||||
返回:
|
||||
RenderResult: 一个包含 `image_bytes` 和 `html_content` 的Pydantic模型。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.renderer.service import RenderContext
|
||||
|
||||
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 未初始化"
|
||||
assert renderer_service._template_engine is not None, "TemplateEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=renderer_service,
|
||||
theme_manager=renderer_service._theme_manager,
|
||||
template_engine=renderer_service._template_engine,
|
||||
screenshot_engine=renderer_service._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
@@ -214,13 +419,30 @@ async def render_full_result(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"builders",
|
||||
"DetailsData",
|
||||
"ListData",
|
||||
"alert",
|
||||
"avatar",
|
||||
"avatar_group",
|
||||
"badge",
|
||||
"card",
|
||||
"component",
|
||||
"create",
|
||||
"divider",
|
||||
"hstack",
|
||||
"kpi_card",
|
||||
"markdown",
|
||||
"notebook",
|
||||
"progress_bar",
|
||||
"register_component",
|
||||
"render",
|
||||
"render_full_result",
|
||||
"render_markdown",
|
||||
"render_template",
|
||||
"table",
|
||||
"template",
|
||||
"text",
|
||||
"timeline",
|
||||
"user_info_block",
|
||||
"vstack",
|
||||
]
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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__ = [
|
||||
"AlertBuilder",
|
||||
"AvatarBuilder",
|
||||
"AvatarGroupBuilder",
|
||||
"BadgeBuilder",
|
||||
"CardBuilder",
|
||||
"DetailsBuilder",
|
||||
"DividerBuilder",
|
||||
"EChartsBuilder",
|
||||
"KpiCardBuilder",
|
||||
"LayoutBuilder",
|
||||
"ListBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginMenuBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"TableBuilder",
|
||||
"TextBuilder",
|
||||
"TimelineBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
@@ -1,117 +0,0 @@
|
||||
from typing import Generic, TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseBuilder(Generic[T_DataModel]):
|
||||
"""
|
||||
所有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._component_css: str | None = None
|
||||
self._variant: str | None = None
|
||||
self._extra_classes: list[str] = []
|
||||
|
||||
@property
|
||||
def data(self) -> T_DataModel:
|
||||
return self._data
|
||||
|
||||
def with_style(self, style_name: str) -> Self:
|
||||
"""
|
||||
为组件应用一个特定的样式。
|
||||
|
||||
参数:
|
||||
style_name: 在主题的CSS中定义的样式类名。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._style_name = style_name
|
||||
return self
|
||||
|
||||
def with_inline_style(self, style: dict[str, str]) -> Self:
|
||||
"""
|
||||
为组件的根元素应用动态的内联样式。
|
||||
|
||||
参数:
|
||||
style: 一个CSS样式字典,例如
|
||||
`{"background-color":"#fff","font-size":"16px"}`。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._inline_style = style
|
||||
return 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._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
|
||||
@@ -1,177 +0,0 @@
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
from ..models.charts import (
|
||||
BaseChartData,
|
||||
EChartsAxis,
|
||||
EChartsData,
|
||||
EChartsGrid,
|
||||
EChartsSeries,
|
||||
EChartsTitle,
|
||||
EChartsTooltip,
|
||||
)
|
||||
from .base import BaseBuilder
|
||||
|
||||
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
|
||||
|
||||
|
||||
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
|
||||
"""
|
||||
一个统一的、泛型的 ECharts 图表构建器。
|
||||
提供了设置 ECharts `option` 的核心方法,以及一些常用图表的便利方法。
|
||||
"""
|
||||
|
||||
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__(model, template_name=template_name)
|
||||
|
||||
def set_title(
|
||||
self, text: str, left: Literal["left", "center", "right"] = "center"
|
||||
) -> Self:
|
||||
self._data.title_model = EChartsTitle(text=text, left=left)
|
||||
return self
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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, type: str, data: list[Any], name: str | None = None, **kwargs: Any
|
||||
) -> Self:
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
小组件构建器模块
|
||||
包含各种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",
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.badge import Badge
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class BadgeBuilder(BaseBuilder[Badge]):
|
||||
"""链式构建徽章组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
color_scheme: Literal[
|
||||
"primary", "success", "warning", "error", "info"
|
||||
] = "info",
|
||||
):
|
||||
data_model = Badge(text=text, color_scheme=color_scheme)
|
||||
super().__init__(data_model, template_name="components/widgets/badge")
|
||||
|
||||
def set_color_scheme(
|
||||
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
|
||||
) -> "BadgeBuilder":
|
||||
"""设置徽章的颜色方案。"""
|
||||
self._data.color_scheme = color_scheme
|
||||
return self
|
||||
@@ -1,20 +0,0 @@
|
||||
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")
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.progress_bar import ProgressBar
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class ProgressBarBuilder(BaseBuilder[ProgressBar]):
|
||||
"""链式构建进度条组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
progress: float,
|
||||
label: str | None = None,
|
||||
color_scheme: Literal[
|
||||
"primary", "success", "warning", "error", "info"
|
||||
] = "primary",
|
||||
animated: bool = False,
|
||||
):
|
||||
data_model = ProgressBar(
|
||||
progress=progress,
|
||||
label=label,
|
||||
color_scheme=color_scheme,
|
||||
animated=animated,
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/progress_bar")
|
||||
|
||||
def set_label(self, label: str) -> "ProgressBarBuilder":
|
||||
"""设置进度条上显示的文本。"""
|
||||
self._data.label = label
|
||||
return self
|
||||
|
||||
def set_color_scheme(
|
||||
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
|
||||
) -> "ProgressBarBuilder":
|
||||
"""设置进度条的颜色方案。"""
|
||||
self._data.color_scheme = color_scheme
|
||||
return self
|
||||
|
||||
def set_animated(self, animated: bool = True) -> "ProgressBarBuilder":
|
||||
"""设置进度条是否显示动画效果。"""
|
||||
self._data.animated = animated
|
||||
return self
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
from ...models.components.user_info_block import UserInfoBlock
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class UserInfoBlockBuilder(BaseBuilder[UserInfoBlock]):
|
||||
"""链式构建用户信息块的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
avatar_url: str,
|
||||
subtitle: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
data_model = UserInfoBlock(
|
||||
name=name, avatar_url=avatar_url, subtitle=subtitle, tags=tags or []
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/user_info_block")
|
||||
|
||||
def set_subtitle(self, subtitle: str) -> "UserInfoBlockBuilder":
|
||||
"""设置副标题。"""
|
||||
self._data.subtitle = subtitle
|
||||
return self
|
||||
|
||||
def add_tag(self, tag: str) -> "UserInfoBlockBuilder":
|
||||
"""添加一个标签。"""
|
||||
self._data.tags.append(tag)
|
||||
return self
|
||||
|
||||
def add_tags(self, tags: list[str]) -> "UserInfoBlockBuilder":
|
||||
"""批量添加标签。"""
|
||||
self._data.tags.extend(tags)
|
||||
return self
|
||||
@@ -1,24 +0,0 @@
|
||||
"""
|
||||
核心构建器模块
|
||||
包含基础的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",
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -1,121 +0,0 @@
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.layout import LayoutData, LayoutItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
__all__ = ["LayoutBuilder"]
|
||||
|
||||
|
||||
class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||
"""
|
||||
一个用于将多个UI组件组合成单张图片的链式构建器。
|
||||
它通过在单个渲染流程中动态包含子模板来实现高质量的输出。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(LayoutData(), template_name="")
|
||||
self._options: dict[str, Any] = {}
|
||||
|
||||
@classmethod
|
||||
def column(
|
||||
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
|
||||
) -> Self:
|
||||
builder = cls()
|
||||
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, *, gap: str = "10px", align_items: str = "center", **options: Any
|
||||
) -> Self:
|
||||
builder = cls()
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def hstack(
|
||||
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||
) -> Self:
|
||||
builder = cls.row(**options)
|
||||
for component in components:
|
||||
builder.add_item(component)
|
||||
return builder
|
||||
|
||||
@classmethod
|
||||
def vstack(
|
||||
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||
) -> Self:
|
||||
builder = cls.column(**options)
|
||||
for component in components:
|
||||
builder.add_item(component)
|
||||
return builder
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
component: "BaseBuilder | RenderableComponent",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
向布局中添加一个组件项。
|
||||
|
||||
参数:
|
||||
component: 一个 `BaseBuilder` 实例 (如 `TableBuilder()`) 或一个已构建的
|
||||
`RenderableComponent` 数据模型。
|
||||
metadata: (可选) 与此项目关联的元数据,可在布局模板中访问。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
component_data = (
|
||||
component.data if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
self._data.children.append(
|
||||
LayoutItem(component=component_data, metadata=metadata)
|
||||
)
|
||||
return self
|
||||
|
||||
def add_option(self, key: str, value: Any) -> Self:
|
||||
"""
|
||||
为布局模板添加一个自定义选项。
|
||||
|
||||
例如,`add_option("padding", "30px")` 会在模板的 `data.options`
|
||||
字典中添加 `{"padding": "30px"}`。
|
||||
|
||||
参数:
|
||||
key: 选项的键名。
|
||||
value: 选项的值。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._options[key] = value
|
||||
return self
|
||||
|
||||
def build(self) -> LayoutData:
|
||||
"""
|
||||
构建并返回 LayoutData 模型实例。
|
||||
"""
|
||||
if not self._template_name:
|
||||
raise ValueError(
|
||||
"必须通过工厂方法 (如 LayoutBuilder.column()) 初始化布局类型。"
|
||||
)
|
||||
|
||||
self._data.options = self._options
|
||||
self._data.layout_type = self._template_name.split("/")[-1]
|
||||
return super().build()
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,162 +0,0 @@
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...models.core.markdown import (
|
||||
CodeElement,
|
||||
ComponentElement,
|
||||
HeadingElement,
|
||||
ImageElement,
|
||||
ListElement,
|
||||
ListItemElement,
|
||||
MarkdownData,
|
||||
MarkdownElement,
|
||||
QuoteElement,
|
||||
RawHtmlElement,
|
||||
RenderableComponent,
|
||||
TableElement,
|
||||
TextElement,
|
||||
)
|
||||
from ..base import BaseBuilder
|
||||
|
||||
__all__ = ["MarkdownBuilder"]
|
||||
|
||||
|
||||
class MarkdownBuilder(BaseBuilder[MarkdownData]):
|
||||
"""链式构建Markdown图片的辅助类,支持上下文管理和组合。"""
|
||||
|
||||
def __init__(self):
|
||||
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
|
||||
self._css_path: str | None = None
|
||||
self._context_stack: list[QuoteElement | ListElement | ListItemElement] = []
|
||||
|
||||
def _append_element(self, element: MarkdownElement):
|
||||
"""内部方法,根据上下文将元素添加到正确的位置。"""
|
||||
if self._context_stack:
|
||||
self._context_stack[-1].content.append(element)
|
||||
else:
|
||||
self._parts.append(element)
|
||||
return self
|
||||
|
||||
def text(self, text: str) -> "MarkdownBuilder":
|
||||
"""添加Markdown文本"""
|
||||
self._append_element(TextElement(text=text))
|
||||
return self
|
||||
|
||||
def head(self, text: str, level: int = 1) -> "MarkdownBuilder":
|
||||
"""添加Markdown标题"""
|
||||
self._append_element(HeadingElement(text=text, level=level))
|
||||
return self
|
||||
|
||||
def image(self, content: str | Path, alt: str = "image") -> "MarkdownBuilder":
|
||||
"""添加Markdown图片"""
|
||||
src = ""
|
||||
if isinstance(content, Path):
|
||||
src = content.absolute().as_uri()
|
||||
elif content.startswith("base64://"):
|
||||
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
else:
|
||||
src = content
|
||||
self._append_element(ImageElement(src=src, alt=alt))
|
||||
return self
|
||||
|
||||
def code(self, code: str, language: str = "") -> "MarkdownBuilder":
|
||||
"""添加Markdown代码块"""
|
||||
self._append_element(CodeElement(code=code, language=language))
|
||||
return self
|
||||
|
||||
def table(
|
||||
self,
|
||||
headers: list[str],
|
||||
rows: list[list[str]],
|
||||
alignments: list[Any] | None = None,
|
||||
) -> "MarkdownBuilder":
|
||||
"""添加Markdown表格"""
|
||||
self._append_element(
|
||||
TableElement(headers=headers, rows=rows, alignments=alignments)
|
||||
)
|
||||
return self
|
||||
|
||||
def add_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:
|
||||
self._context_stack[-1].content.extend(builder._parts)
|
||||
else:
|
||||
self._parts.extend(builder._parts)
|
||||
return self
|
||||
|
||||
def quote(self) -> AbstractContextManager["MarkdownBuilder"]:
|
||||
"""创建一个引用块上下文。"""
|
||||
return self._context_for(QuoteElement())
|
||||
|
||||
def list(self, ordered: bool = False) -> AbstractContextManager["MarkdownBuilder"]:
|
||||
"""创建一个列表上下文。"""
|
||||
return self._context_for(ListElement(ordered=ordered))
|
||||
|
||||
def list_item(self) -> AbstractContextManager["MarkdownBuilder"]:
|
||||
"""在列表上下文中创建一个列表项。"""
|
||||
if not self._context_stack or not isinstance(
|
||||
self._context_stack[-1], ListElement
|
||||
):
|
||||
raise TypeError("list_item() 只能在 list() 上下文中使用。")
|
||||
return self._context_for(ListItemElement())
|
||||
|
||||
class _ContextManager:
|
||||
def __init__(
|
||||
self,
|
||||
builder: "MarkdownBuilder",
|
||||
element: QuoteElement | ListElement | ListItemElement,
|
||||
):
|
||||
self.builder = builder
|
||||
self.element = element
|
||||
|
||||
def __enter__(self):
|
||||
self.builder._context_stack.append(self.element)
|
||||
return self.builder
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
del exc_type, exc_val, exc_tb
|
||||
self.builder._context_stack.pop()
|
||||
|
||||
def _context_for(
|
||||
self, element: QuoteElement | ListElement | ListItemElement
|
||||
) -> AbstractContextManager["MarkdownBuilder"]:
|
||||
self._append_element(element)
|
||||
return self._ContextManager(self, element)
|
||||
|
||||
def set_width(self, width: int) -> "MarkdownBuilder":
|
||||
"""设置图片宽度"""
|
||||
self._width = width
|
||||
return self
|
||||
|
||||
def set_css_path(self, css_path: str) -> "MarkdownBuilder":
|
||||
"""设置CSS样式路径"""
|
||||
self._css_path = css_path
|
||||
return self
|
||||
|
||||
def add_divider(self) -> "MarkdownBuilder":
|
||||
"""添加一条标准的 Markdown 分割线。"""
|
||||
self._append_element(RawHtmlElement(html="---"))
|
||||
return self
|
||||
|
||||
def build(self) -> MarkdownData:
|
||||
"""
|
||||
构建并返回 MarkdownData 模型实例。
|
||||
"""
|
||||
self._data.elements = self._parts
|
||||
self._data.width = self._width
|
||||
self._data.css_path = self._css_path
|
||||
return super().build()
|
||||
@@ -1,120 +0,0 @@
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.notebook import NotebookData, NotebookElement
|
||||
from ..base import BaseBuilder
|
||||
|
||||
__all__ = ["NotebookBuilder"]
|
||||
|
||||
|
||||
class NotebookBuilder(BaseBuilder[NotebookData]):
|
||||
"""
|
||||
一个用于链式构建 Notebook 页面的辅助类。
|
||||
"""
|
||||
|
||||
def __init__(self, data: list[NotebookElement] | None = None):
|
||||
elements = data if data is not None else []
|
||||
data_model = NotebookData(elements=elements)
|
||||
super().__init__(data_model, template_name="components/core/notebook")
|
||||
self._elements = elements
|
||||
|
||||
def text(self, text: str) -> "NotebookBuilder":
|
||||
"""添加Notebook文本"""
|
||||
self._elements.append(NotebookElement(type="paragraph", text=text))
|
||||
return self
|
||||
|
||||
def head(self, text: str, level: int = 1) -> "NotebookBuilder":
|
||||
"""添加Notebook标题"""
|
||||
if not 1 <= level <= 4:
|
||||
raise ValueError("标题级别必须在1-4之间")
|
||||
self._elements.append(NotebookElement(type="heading", text=text, level=level))
|
||||
return self
|
||||
|
||||
def image(
|
||||
self,
|
||||
content: str,
|
||||
caption: str | None = None,
|
||||
) -> "NotebookBuilder":
|
||||
"""添加Notebook图片"""
|
||||
src = ""
|
||||
if isinstance(content, Path):
|
||||
src = content.absolute().as_uri()
|
||||
elif content.startswith("base64"):
|
||||
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
else:
|
||||
src = content
|
||||
self._elements.append(NotebookElement(type="image", src=src, caption=caption))
|
||||
return self
|
||||
|
||||
def quote(self, text: str | list[str]) -> "NotebookBuilder":
|
||||
"""添加Notebook引用文本"""
|
||||
if isinstance(text, str):
|
||||
self._elements.append(NotebookElement(type="blockquote", text=text))
|
||||
elif isinstance(text, list):
|
||||
for t in text:
|
||||
self._elements.append(NotebookElement(type="blockquote", text=t))
|
||||
return self
|
||||
|
||||
def code(self, code: str, language: str = "python") -> "NotebookBuilder":
|
||||
"""添加Notebook代码块"""
|
||||
self._elements.append(
|
||||
NotebookElement(type="code", code=code, language=language)
|
||||
)
|
||||
return self
|
||||
|
||||
def list(self, items: list[str], ordered: bool = False) -> "NotebookBuilder":
|
||||
"""添加Notebook列表"""
|
||||
self._elements.append(NotebookElement(type="list", data=items, ordered=ordered))
|
||||
return self
|
||||
|
||||
def add_divider(self, **kwargs) -> "NotebookBuilder":
|
||||
"""
|
||||
添加分隔线。
|
||||
:param kwargs: Divider组件的可选参数, 如 margin, color, style, thickness。
|
||||
"""
|
||||
from ...models.components import Divider
|
||||
|
||||
self.add_component(Divider(**kwargs))
|
||||
return self
|
||||
|
||||
def add_component(
|
||||
self, component: "RenderableComponent | BaseBuilder"
|
||||
) -> "NotebookBuilder":
|
||||
"""
|
||||
向 Notebook 中添加一个可渲染的自定义组件。
|
||||
|
||||
"""
|
||||
component_data = (
|
||||
component.data if isinstance(component, BaseBuilder) else component
|
||||
)
|
||||
|
||||
if not isinstance(component_data, RenderableComponent):
|
||||
raise TypeError(
|
||||
f"add_component 只能接受 RenderableComponent 或其 Builder,"
|
||||
f"但收到了 {type(component)}"
|
||||
)
|
||||
|
||||
self._elements.append(
|
||||
NotebookElement(type="component", component=component_data)
|
||||
)
|
||||
return self
|
||||
|
||||
def add_texts(self, texts: builtins.list[str]) -> "NotebookBuilder":
|
||||
"""批量添加多个文本段落"""
|
||||
for text in texts:
|
||||
self.text(text)
|
||||
return self
|
||||
|
||||
def add_quotes(self, quotes: builtins.list[str]) -> "NotebookBuilder":
|
||||
"""批量添加引用"""
|
||||
for quote in quotes:
|
||||
self.quote(quote)
|
||||
return self
|
||||
|
||||
def build(self) -> NotebookData:
|
||||
"""
|
||||
构建并返回 NotebookData 模型实例。
|
||||
"""
|
||||
self._data.elements = self._elements
|
||||
return super().build()
|
||||
@@ -1,105 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from ...models.core.table import (
|
||||
BaseCell,
|
||||
ImageCell,
|
||||
TableCell,
|
||||
TableData,
|
||||
TextCell,
|
||||
)
|
||||
from ..base import BaseBuilder
|
||||
|
||||
__all__ = ["TableBuilder"]
|
||||
|
||||
|
||||
class TableBuilder(BaseBuilder[TableData]):
|
||||
"""链式构建通用表格的辅助类"""
|
||||
|
||||
def __init__(self, title: str, tip: str | None = None):
|
||||
data_model = TableData(title=title, tip=tip, headers=[], rows=[])
|
||||
super().__init__(data_model, template_name="components/core/table")
|
||||
|
||||
def _normalize_cell(self, cell_data: Any) -> TableCell:
|
||||
"""内部辅助方法,将各种原生数据类型转换为TableCell模型。"""
|
||||
if isinstance(cell_data, BaseCell):
|
||||
return cell_data # type: ignore
|
||||
if isinstance(cell_data, str | int | float):
|
||||
return TextCell(content=str(cell_data))
|
||||
if isinstance(cell_data, Path):
|
||||
return ImageCell(src=cell_data.resolve().as_uri())
|
||||
if isinstance(cell_data, tuple) and len(cell_data) == 3:
|
||||
if (
|
||||
isinstance(cell_data[0], Path)
|
||||
and isinstance(cell_data[1], int)
|
||||
and isinstance(cell_data[2], int)
|
||||
):
|
||||
return ImageCell(
|
||||
src=cell_data[0].resolve().as_uri(),
|
||||
width=cell_data[1],
|
||||
height=cell_data[2],
|
||||
)
|
||||
|
||||
return TextCell(content="")
|
||||
|
||||
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: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
normalized_row = [self._normalize_cell(cell) for cell in row]
|
||||
self._data.rows.append(normalized_row)
|
||||
return self
|
||||
|
||||
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
|
||||
"""
|
||||
向表格中批量添加多行数据, 并自动转换原生类型。
|
||||
|
||||
参数:
|
||||
rows: 一个包含多行数据的列表。
|
||||
|
||||
返回:
|
||||
TableBuilder: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
for row in rows:
|
||||
self.add_row(row)
|
||||
return self
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
预设构建器模块
|
||||
包含预定义的UI组件构建器
|
||||
"""
|
||||
|
||||
from .plugin_help_page import PluginHelpPageBuilder
|
||||
from .plugin_menu import PluginMenuBuilder
|
||||
|
||||
__all__ = [
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginMenuBuilder",
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from ...models.presets.plugin_help_page import (
|
||||
HelpCategory,
|
||||
PluginHelpPageData,
|
||||
)
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class PluginHelpPageBuilder(BaseBuilder[PluginHelpPageData]):
|
||||
"""链式构建插件帮助页面的辅助类"""
|
||||
|
||||
def __init__(self, bot_nickname: str, page_title: str):
|
||||
self._data = PluginHelpPageData(
|
||||
bot_nickname=bot_nickname, page_title=page_title, categories=[]
|
||||
)
|
||||
|
||||
super().__init__(self._data, template_name="pages/core/plugin_help_page")
|
||||
|
||||
def add_category(self, category: HelpCategory) -> "PluginHelpPageBuilder":
|
||||
"""添加一个帮助分类"""
|
||||
self._data.categories.append(category)
|
||||
return self
|
||||
|
||||
def add_categories(self, categories: list[HelpCategory]) -> "PluginHelpPageBuilder":
|
||||
"""批量添加帮助分类"""
|
||||
for category in categories:
|
||||
self.add_category(category)
|
||||
return self
|
||||
@@ -1,36 +0,0 @@
|
||||
from ...models.presets.plugin_menu import (
|
||||
PluginMenuCategory,
|
||||
PluginMenuData,
|
||||
)
|
||||
from ..base import BaseBuilder
|
||||
|
||||
__all__ = ["PluginMenuBuilder"]
|
||||
|
||||
|
||||
class PluginMenuBuilder(BaseBuilder[PluginMenuData]):
|
||||
"""链式构建插件菜单的辅助类"""
|
||||
|
||||
def __init__(self, bot_name: str, bot_avatar_url: str, is_detail: bool = False):
|
||||
self._data = PluginMenuData(
|
||||
bot_name=bot_name,
|
||||
bot_avatar_url=bot_avatar_url,
|
||||
is_detail=is_detail,
|
||||
plugin_count=0,
|
||||
active_count=0,
|
||||
categories=[],
|
||||
)
|
||||
|
||||
super().__init__(self._data, template_name="pages/core/plugin_menu")
|
||||
|
||||
def add_category(self, category: PluginMenuCategory) -> "PluginMenuBuilder":
|
||||
self._data.categories.append(category)
|
||||
self._data.plugin_count += len(category.items)
|
||||
self._data.active_count += sum(1 for item in category.items if item.status)
|
||||
return self
|
||||
|
||||
def add_categories(
|
||||
self, categories: list[PluginMenuCategory]
|
||||
) -> "PluginMenuBuilder":
|
||||
for category in categories:
|
||||
self.add_category(category)
|
||||
return self
|
||||
+144
-1
@@ -1,5 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal
|
||||
from typing_extensions import Self
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -94,7 +95,10 @@ class BaseChartData(RenderableComponent, ABC):
|
||||
class EChartsData(BaseChartData):
|
||||
"""统一的 ECharts 图表数据模型"""
|
||||
|
||||
template_path: str = Field(..., exclude=True, description="图表组件的模板路径")
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
template_path: str = Field(..., exclude=True, description="图表组件的模板路径") # type: ignore
|
||||
"""图表组件的模板路径"""
|
||||
title_model: EChartsTitle | None = Field(
|
||||
None, alias="title", description="标题组件"
|
||||
@@ -160,3 +164,142 @@ class EChartsData(BaseChartData):
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return self.template_path
|
||||
|
||||
def set_title(
|
||||
self, text: str, left: Literal["left", "center", "right"] = "center"
|
||||
) -> Self:
|
||||
self.title_model = EChartsTitle(text=text, left=left)
|
||||
return self
|
||||
|
||||
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.grid_model = EChartsGrid(
|
||||
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
|
||||
)
|
||||
return self
|
||||
|
||||
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
|
||||
self.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.x_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
return self
|
||||
|
||||
def set_y_axis(
|
||||
self,
|
||||
type: Literal["category", "value", "time", "log"],
|
||||
data: list[Any] | None = None,
|
||||
show: bool = True,
|
||||
) -> Self:
|
||||
self.y_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
return self
|
||||
|
||||
def add_series(
|
||||
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
|
||||
) -> Self:
|
||||
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
|
||||
self.series_models.append(series)
|
||||
return self
|
||||
|
||||
def set_legend(
|
||||
self,
|
||||
data: list[str],
|
||||
orient: Literal["horizontal", "vertical"] = "horizontal",
|
||||
left: str = "auto",
|
||||
) -> Self:
|
||||
self.legend_model = {"data": data, "orient": orient, "left": left}
|
||||
return self
|
||||
|
||||
def set_option(self, key: str, value: Any) -> Self:
|
||||
self.raw_options[key] = value
|
||||
return self
|
||||
|
||||
def set_background_image(self, image_name: str) -> Self:
|
||||
self.background_image = image_name
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def bar_chart(
|
||||
cls,
|
||||
title: str,
|
||||
items: list[tuple[str, int | float]],
|
||||
direction: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_image: str | None = None,
|
||||
) -> "EChartsData":
|
||||
"""便捷创建一个柱状图"""
|
||||
categories = [item[0] for item in items]
|
||||
values = [item[1] for item in items]
|
||||
|
||||
if direction == "horizontal":
|
||||
x_axis = EChartsAxis(type="value")
|
||||
y_axis = EChartsAxis(type="category", data=categories)
|
||||
else:
|
||||
x_axis = EChartsAxis(type="category", data=categories)
|
||||
y_axis = EChartsAxis(type="value")
|
||||
|
||||
return cls(
|
||||
template_path="components/charts/bar_chart",
|
||||
title=EChartsTitle(text=title),
|
||||
grid=None,
|
||||
xAxis=x_axis,
|
||||
yAxis=y_axis,
|
||||
tooltip=EChartsTooltip(trigger="item"),
|
||||
series=[EChartsSeries(type="bar", data=values)],
|
||||
background_image=background_image,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def pie_chart(
|
||||
cls, title: str, items: list[tuple[str, int | float]]
|
||||
) -> "EChartsData":
|
||||
"""便捷创建一个饼图"""
|
||||
data = [{"name": name, "value": value} for name, value in items]
|
||||
legend_data = [item[0] for item in items]
|
||||
return cls(
|
||||
template_path="components/charts/pie_chart",
|
||||
title=EChartsTitle(text=title),
|
||||
grid=None,
|
||||
tooltip=EChartsTooltip(trigger="item"),
|
||||
xAxis=None,
|
||||
yAxis=None,
|
||||
legend={"data": legend_data, "orient": "horizontal", "left": "auto"},
|
||||
series=[EChartsSeries(type="pie", data=data, name=title)],
|
||||
background_image=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def line_chart(
|
||||
cls, title: str, categories: list[str], series: list[dict[str, Any]]
|
||||
) -> "EChartsData":
|
||||
"""便捷创建一个折线图"""
|
||||
series_models = [
|
||||
EChartsSeries(
|
||||
type="line",
|
||||
name=s.get("name", ""),
|
||||
data=s.get("data", []),
|
||||
smooth=s.get("smooth", False),
|
||||
)
|
||||
for s in series
|
||||
]
|
||||
return cls(
|
||||
template_path="components/charts/line_chart",
|
||||
title=EChartsTitle(text=title),
|
||||
grid=None,
|
||||
xAxis=EChartsAxis(type="category", data=categories),
|
||||
yAxis=EChartsAxis(type="value"),
|
||||
tooltip=EChartsTooltip(trigger="axis"),
|
||||
series=series_models,
|
||||
background_image=None,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""
|
||||
组件模型模块
|
||||
包含各种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
|
||||
from .data import KpiCard, Timeline, TimelineItem
|
||||
from .display import Avatar, AvatarGroup, Divider, Rectangle, UserInfoBlock
|
||||
from .feedback import Alert, Badge, ProgressBar
|
||||
|
||||
__all__ = [
|
||||
"Alert",
|
||||
"Avatar",
|
||||
"AvatarGroup",
|
||||
"Badge",
|
||||
"Divider",
|
||||
"KpiCard",
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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"
|
||||
@@ -1,41 +0,0 @@
|
||||
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")
|
||||
"""头像的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'"
|
||||
)
|
||||
"""最多显示的头像数量,超出部分会显示为'+N'"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/avatar"
|
||||
@@ -1,24 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Badge"]
|
||||
|
||||
|
||||
class Badge(RenderableComponent):
|
||||
"""一个简单的徽章组件,用于显示状态或标签。"""
|
||||
|
||||
component_type: Literal["badge"] = "badge"
|
||||
text: str = Field(..., description="徽章上显示的文本")
|
||||
"""徽章上显示的文本"""
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
|
||||
default="info",
|
||||
description="预设的颜色方案",
|
||||
)
|
||||
"""预设的颜色方案"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/badge"
|
||||
@@ -1,16 +1,17 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["KpiCard"]
|
||||
__all__ = ["KpiCard", "Timeline", "TimelineItem"]
|
||||
|
||||
|
||||
class KpiCard(RenderableComponent):
|
||||
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
|
||||
|
||||
component_type: Literal["kpi_card"] = "kpi_card"
|
||||
"""组件类型"""
|
||||
label: str = Field(..., description="指标的标签或名称")
|
||||
"""指标的标签或名称"""
|
||||
value: Any = Field(..., description="指标的主要数值")
|
||||
@@ -33,3 +34,33 @@ class KpiCard(RenderableComponent):
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/kpi_card"
|
||||
|
||||
|
||||
class TimelineItem(BaseModel):
|
||||
"""时间轴中的单个事件点。"""
|
||||
|
||||
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
|
||||
"""显示在时间点旁边的时间或标签"""
|
||||
title: str = Field(..., description="事件的标题")
|
||||
"""事件的标题"""
|
||||
content: str = Field(..., description="事件的详细描述")
|
||||
"""事件的详细描述"""
|
||||
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
|
||||
"""可选的自定义图标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"
|
||||
@@ -0,0 +1,102 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Avatar", "AvatarGroup", "Divider", "Rectangle", "UserInfoBlock"]
|
||||
|
||||
|
||||
class Avatar(RenderableComponent):
|
||||
"""单个头像组件。"""
|
||||
|
||||
component_type: Literal["avatar"] = "avatar"
|
||||
"""组件类型"""
|
||||
src: str = Field(..., description="头像的URL或Base64数据URI")
|
||||
"""头像的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'"
|
||||
)
|
||||
"""最多显示的头像数量,超出部分会显示为'+N'"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/avatar"
|
||||
|
||||
|
||||
class Divider(RenderableComponent):
|
||||
"""一个简单的分割线组件。"""
|
||||
|
||||
component_type: Literal["divider"] = "divider"
|
||||
"""组件类型"""
|
||||
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
|
||||
"""CSS margin属性,控制分割线上下的间距"""
|
||||
color: str = Field("#f7889c", description="分割线颜色")
|
||||
"""分割线颜色"""
|
||||
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
|
||||
"""线条样式"""
|
||||
thickness: str = Field("1px", description="线条粗细")
|
||||
"""线条粗细"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/divider"
|
||||
|
||||
|
||||
class Rectangle(RenderableComponent):
|
||||
"""一个矩形背景块组件。"""
|
||||
|
||||
component_type: Literal["rectangle"] = "rectangle"
|
||||
"""组件类型"""
|
||||
height: str = Field("50px", description="矩形的高度 (CSS value)")
|
||||
"""矩形的高度 (CSS value)"""
|
||||
background_color: str = Field("#fdf1f5", description="背景颜色")
|
||||
"""背景颜色"""
|
||||
border: str = Field("1px solid #fce4ec", description="CSS border属性")
|
||||
"""CSS border属性"""
|
||||
border_radius: str = Field("8px", description="CSS border-radius属性")
|
||||
"""CSS border-radius属性"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/rectangle"
|
||||
|
||||
|
||||
class UserInfoBlock(RenderableComponent):
|
||||
"""一个带头像、名称和副标题的用户信息块组件。"""
|
||||
|
||||
component_type: Literal["user_info_block"] = "user_info_block"
|
||||
"""组件类型"""
|
||||
avatar_url: str = Field(..., description="用户头像的URL")
|
||||
"""用户头像的URL"""
|
||||
name: str = Field(..., description="用户的名称")
|
||||
"""用户的名称"""
|
||||
subtitle: str | None = Field(
|
||||
default=None, description="显示在名称下方的副标题 (如UID或角色)"
|
||||
)
|
||||
"""显示在名称下方的副标题 (如UID或角色)"""
|
||||
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
|
||||
"""附加的标签列表"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/user_info_block"
|
||||
@@ -1,43 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Divider", "Rectangle"]
|
||||
|
||||
|
||||
class Divider(RenderableComponent):
|
||||
"""一个简单的分割线组件。"""
|
||||
|
||||
component_type: Literal["divider"] = "divider"
|
||||
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
|
||||
"""CSS margin属性,控制分割线上下的间距"""
|
||||
color: str = Field("#f7889c", description="分割线颜色")
|
||||
"""分割线颜色"""
|
||||
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
|
||||
"""线条样式"""
|
||||
thickness: str = Field("1px", description="线条粗细")
|
||||
"""线条粗细"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/divider"
|
||||
|
||||
|
||||
class Rectangle(RenderableComponent):
|
||||
"""一个矩形背景块组件。"""
|
||||
|
||||
component_type: Literal["rectangle"] = "rectangle"
|
||||
height: str = Field("50px", description="矩形的高度 (CSS value)")
|
||||
"""矩形的高度 (CSS value)"""
|
||||
background_color: str = Field("#fdf1f5", description="背景颜色")
|
||||
"""背景颜色"""
|
||||
border: str = Field("1px solid #fce4ec", description="CSS border属性")
|
||||
"""CSS border属性"""
|
||||
border_radius: str = Field("8px", description="CSS border-radius属性")
|
||||
"""CSS border-radius属性"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/rectangle"
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ...registry import component
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["Alert", "Badge", "ProgressBar"]
|
||||
|
||||
|
||||
@component(name="alert", namespace="core")
|
||||
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"
|
||||
|
||||
|
||||
class Badge(RenderableComponent):
|
||||
"""一个简单的徽章组件,用于显示状态或标签。"""
|
||||
|
||||
component_type: Literal["badge"] = "badge"
|
||||
"""组件类型"""
|
||||
text: str = Field(..., description="徽章上显示的文本")
|
||||
"""徽章上显示的文本"""
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
|
||||
default="info",
|
||||
description="预设的颜色方案",
|
||||
)
|
||||
"""预设的颜色方案"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/badge"
|
||||
|
||||
|
||||
class ProgressBar(RenderableComponent):
|
||||
"""一个进度条组件。"""
|
||||
|
||||
component_type: Literal["progress_bar"] = "progress_bar"
|
||||
"""组件类型"""
|
||||
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
|
||||
"""进度百分比 (0-100)"""
|
||||
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
|
||||
"""显示在进度条上的可选文本"""
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
|
||||
default="primary",
|
||||
description="预设的颜色方案",
|
||||
)
|
||||
"""预设的颜色方案"""
|
||||
animated: bool = Field(default=False, description="是否显示动画效果")
|
||||
"""是否显示动画效果"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/progress_bar"
|
||||
@@ -1,28 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["ProgressBar"]
|
||||
|
||||
|
||||
class ProgressBar(RenderableComponent):
|
||||
"""一个进度条组件。"""
|
||||
|
||||
component_type: Literal["progress_bar"] = "progress_bar"
|
||||
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
|
||||
"""进度百分比 (0-100)"""
|
||||
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
|
||||
"""显示在进度条上的可选文本"""
|
||||
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
|
||||
default="primary",
|
||||
description="预设的颜色方案",
|
||||
)
|
||||
"""预设的颜色方案"""
|
||||
animated: bool = Field(default=False, description="是否显示动画效果")
|
||||
"""是否显示动画效果"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/progress_bar"
|
||||
@@ -1,36 +0,0 @@
|
||||
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路径")
|
||||
"""可选的自定义图标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"
|
||||
@@ -1,27 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..core.base import RenderableComponent
|
||||
|
||||
__all__ = ["UserInfoBlock"]
|
||||
|
||||
|
||||
class UserInfoBlock(RenderableComponent):
|
||||
"""一个带头像、名称和副标题的用户信息块组件。"""
|
||||
|
||||
component_type: Literal["user_info_block"] = "user_info_block"
|
||||
avatar_url: str = Field(..., description="用户头像的URL")
|
||||
"""用户头像的URL"""
|
||||
name: str = Field(..., description="用户的名称")
|
||||
"""用户的名称"""
|
||||
subtitle: str | None = Field(
|
||||
default=None, description="显示在名称下方的副标题 (如UID或角色)"
|
||||
)
|
||||
"""显示在名称下方的副标题 (如UID或角色)"""
|
||||
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
|
||||
"""附加的标签列表"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/widgets/user_info_block"
|
||||
@@ -1,45 +1,48 @@
|
||||
"""
|
||||
核心模型模块
|
||||
包含基础的数据模型类
|
||||
"""
|
||||
|
||||
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 (
|
||||
from .containers import (
|
||||
CardData,
|
||||
LayoutData,
|
||||
LayoutItem,
|
||||
ListData,
|
||||
ListItem,
|
||||
NotebookData,
|
||||
NotebookElement,
|
||||
TemplateComponent,
|
||||
)
|
||||
from .content import (
|
||||
BaseCell,
|
||||
CodeElement,
|
||||
ComponentCell,
|
||||
ComponentElement,
|
||||
DetailsData,
|
||||
DetailsItem,
|
||||
HeadingElement,
|
||||
ImageCell,
|
||||
ImageElement,
|
||||
ListElement,
|
||||
ListItemElement,
|
||||
MarkdownData,
|
||||
MarkdownElement,
|
||||
ProgressBarCell,
|
||||
QuoteElement,
|
||||
RawHtmlElement,
|
||||
TableElement,
|
||||
TextElement,
|
||||
)
|
||||
from .notebook import NotebookData, NotebookElement
|
||||
from .table import (
|
||||
BaseCell,
|
||||
ComponentCell,
|
||||
ImageCell,
|
||||
RichTextCell,
|
||||
StatusBadgeCell,
|
||||
TableCell,
|
||||
TableData,
|
||||
TableElement,
|
||||
TextCell,
|
||||
TextData,
|
||||
TextElement,
|
||||
TextSpan,
|
||||
)
|
||||
from .template import TemplateComponent
|
||||
from .text import TextData, TextSpan
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"CardData",
|
||||
"CodeElement",
|
||||
"ComponentCell",
|
||||
"ComponentElement",
|
||||
"DetailsData",
|
||||
"DetailsItem",
|
||||
"HeadingElement",
|
||||
@@ -55,6 +58,7 @@ __all__ = [
|
||||
"MarkdownElement",
|
||||
"NotebookData",
|
||||
"NotebookElement",
|
||||
"ProgressBarCell",
|
||||
"QuoteElement",
|
||||
"RawHtmlElement",
|
||||
"RenderableComponent",
|
||||
|
||||
+118
-23
@@ -1,22 +1,45 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from abc import ABC
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import VERSION as PYDANTIC_VERSION
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
from zhenxun.services.renderer.types import Renderable
|
||||
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
|
||||
|
||||
__all__ = ["ContainerComponent", "RenderableComponent"]
|
||||
|
||||
|
||||
def _iter_renderables(obj: Any) -> Iterable["Renderable"]:
|
||||
"""
|
||||
递归遍历对象,查找所有 Renderable 实例。
|
||||
支持列表、字典以及嵌套的 Pydantic 模型。
|
||||
"""
|
||||
if isinstance(obj, Renderable):
|
||||
yield obj
|
||||
elif isinstance(obj, list | tuple):
|
||||
for item in obj:
|
||||
yield from _iter_renderables(item)
|
||||
elif isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
yield from _iter_renderables(value)
|
||||
elif isinstance(obj, BaseModel):
|
||||
if PYDANTIC_VERSION.startswith("1"):
|
||||
fields = obj.__fields__
|
||||
else:
|
||||
fields = obj.model_fields # type: ignore
|
||||
|
||||
for field_name in fields:
|
||||
value = getattr(obj, field_name)
|
||||
yield from _iter_renderables(value)
|
||||
|
||||
|
||||
class RenderableComponent(BaseModel, Renderable):
|
||||
"""
|
||||
所有可渲染UI组件的数据模型基类。
|
||||
|
||||
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
|
||||
协议,确保其能够被 `RendererService` 正确处理。
|
||||
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
|
||||
提供通用的样式属性(如内联样式、CSS类、变体)和链式调用方法。
|
||||
"""
|
||||
|
||||
_is_standalone_template: bool = False
|
||||
@@ -29,24 +52,103 @@ class RenderableComponent(BaseModel, Renderable):
|
||||
"""应用于组件根元素的额外CSS类名列表"""
|
||||
variant: str | None = None
|
||||
"""组件的变体/皮肤名称"""
|
||||
style_name: str | None = None
|
||||
"""组件的样式名称"""
|
||||
is_page: bool = False
|
||||
"""标记此组件是否为完整页面(自带html/body), 渲染时将跳过通用包装器"""
|
||||
template_path: str | None = Field(
|
||||
default=None, description="动态覆盖的模板路径", exclude=True
|
||||
)
|
||||
"""动态覆盖的模板路径,若设置则优先于 template_name 属性"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
"""
|
||||
返回用于渲染此组件的Jinja2模板的路径。
|
||||
这是一个抽象属性,所有子类都必须覆盖它。
|
||||
返回用于渲染此组件的 Jinja2 模板路径。
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement the 'template_name' property."
|
||||
)
|
||||
return ""
|
||||
|
||||
def with_style(self, style_name: str) -> Self:
|
||||
"""
|
||||
设置组件样式名称。
|
||||
|
||||
参数:
|
||||
style_name: 样式名称,通常对应主题中的一组CSS定义
|
||||
"""
|
||||
self.style_name = style_name
|
||||
return self
|
||||
|
||||
def with_variant(self, variant: str) -> Self:
|
||||
"""
|
||||
设置组件变体(皮肤)。
|
||||
|
||||
参数:
|
||||
variant: 变体名称,用于加载不同的模板或样式集
|
||||
"""
|
||||
self.variant = variant
|
||||
return self
|
||||
|
||||
def with_classes(self, *classes: str) -> Self:
|
||||
"""
|
||||
添加 CSS 类名。
|
||||
|
||||
参数:
|
||||
*classes: 一个或多个 CSS 类名
|
||||
"""
|
||||
if self.extra_classes is None:
|
||||
self.extra_classes = []
|
||||
self.extra_classes.extend(classes)
|
||||
return self
|
||||
|
||||
def with_inline_style(self, style: dict[str, str]) -> Self:
|
||||
"""
|
||||
设置内联 CSS 样式。
|
||||
|
||||
参数:
|
||||
style: 样式键值对字典 (e.g. {'color': 'red'})
|
||||
"""
|
||||
if self.inline_style is None:
|
||||
self.inline_style = {}
|
||||
self.inline_style.update(style)
|
||||
return self
|
||||
|
||||
def with_component_css(self, css: str) -> Self:
|
||||
"""
|
||||
注入自定义 CSS 代码块。
|
||||
|
||||
参数:
|
||||
css: CSS 代码字符串
|
||||
"""
|
||||
self.component_css = css
|
||||
return self
|
||||
|
||||
def update(self, **kwargs) -> Self:
|
||||
"""批量更新组件属性。"""
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
return self
|
||||
|
||||
def build(self) -> Self:
|
||||
"""
|
||||
返回组件自身(兼容 Builder 模式调用)。
|
||||
"""
|
||||
return self
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""[可选] 生命周期钩子,默认无操作。"""
|
||||
"""[生命周期] 渲染前的异步准备步骤。"""
|
||||
pass
|
||||
|
||||
def get_children(self) -> Iterable["RenderableComponent"]:
|
||||
"""默认实现:非容器组件没有子组件。"""
|
||||
return []
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
"""获取所有子组件的迭代器。"""
|
||||
if PYDANTIC_VERSION.startswith("1"):
|
||||
fields = self.__fields__
|
||||
else:
|
||||
fields = self.model_fields # type: ignore
|
||||
|
||||
for field_name in fields:
|
||||
value = getattr(self, field_name)
|
||||
yield from _iter_renderables(value)
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
|
||||
@@ -78,13 +180,6 @@ class ContainerComponent(RenderableComponent, ABC):
|
||||
一个为容器类组件设计的抽象基类,封装了预渲染子组件的通用逻辑。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
"""
|
||||
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""聚合所有子组件的脚本依赖。"""
|
||||
scripts = set(super().get_required_scripts())
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,294 @@
|
||||
import builtins
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...registry import component
|
||||
from .base import ContainerComponent, Renderable, RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"CardData",
|
||||
"LayoutData",
|
||||
"LayoutItem",
|
||||
"ListData",
|
||||
"ListItem",
|
||||
"NotebookData",
|
||||
"NotebookElement",
|
||||
"TemplateComponent",
|
||||
]
|
||||
|
||||
|
||||
class TemplateComponent(RenderableComponent):
|
||||
"""基于独立模板文件的UI组件"""
|
||||
|
||||
_is_standalone_template: bool = True
|
||||
template_path: str | Path = Field(..., description="指向HTML模板文件的路径") # type: ignore
|
||||
"""指向HTML模板文件的路径"""
|
||||
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
|
||||
"""传递给模板的上下文数据字典"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
if isinstance(self.template_path, Path):
|
||||
return self.template_path.as_posix()
|
||||
return str(self.template_path)
|
||||
|
||||
def get_render_data(self) -> dict[str, Any]:
|
||||
return self.data
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
try:
|
||||
return self.data[name]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' 对象没有属性 '{name}'"
|
||||
) from None
|
||||
|
||||
|
||||
@component(name="card", namespace="core")
|
||||
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["Renderable"]:
|
||||
if self.header:
|
||||
yield self.header
|
||||
if self.content:
|
||||
yield self.content
|
||||
if self.footer:
|
||||
yield self.footer
|
||||
|
||||
def set_header(self, header: "RenderableComponent") -> Self:
|
||||
self.header = header
|
||||
return self
|
||||
|
||||
def set_footer(self, footer: "RenderableComponent") -> Self:
|
||||
self.footer = footer
|
||||
return self
|
||||
|
||||
|
||||
class LayoutItem(BaseModel):
|
||||
"""布局中的单个项目"""
|
||||
|
||||
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
|
||||
"""要渲染的组件的数据模型"""
|
||||
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
|
||||
"""传递给模板的额外元数据"""
|
||||
|
||||
|
||||
class LayoutData(ContainerComponent):
|
||||
"""布局构建器的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
layout_type: str = "column"
|
||||
children: list[LayoutItem] = Field(
|
||||
default_factory=list, description="要布局的项目列表"
|
||||
)
|
||||
"""要布局的项目列表"""
|
||||
options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给模板的选项"
|
||||
)
|
||||
"""传递给模板的选项"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return f"components/core/layouts/{self.layout_type}"
|
||||
|
||||
@classmethod
|
||||
def column(
|
||||
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
|
||||
) -> Self:
|
||||
options.update({"gap": gap, "align_items": align_items})
|
||||
return cls(layout_type="column", options=options)
|
||||
|
||||
@classmethod
|
||||
def row(
|
||||
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
|
||||
) -> Self:
|
||||
options.update({"gap": gap, "align_items": align_items})
|
||||
return cls(layout_type="row", options=options)
|
||||
|
||||
@classmethod
|
||||
def grid(cls, columns: int = 2, **options: Any) -> Self:
|
||||
options.update({"columns": columns})
|
||||
return cls(layout_type="grid", options=options)
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
component: "RenderableComponent",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Self:
|
||||
self.children.append(LayoutItem(component=component, metadata=metadata))
|
||||
return self
|
||||
|
||||
def add_option(self, key: str, value: Any) -> Self:
|
||||
self.options[key] = value
|
||||
return self
|
||||
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
for item in self.children:
|
||||
if item.component:
|
||||
yield item.component
|
||||
|
||||
def get_extra_css(self, context: Any) -> str:
|
||||
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)
|
||||
|
||||
|
||||
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["Renderable"]:
|
||||
for item in self.items:
|
||||
if item.component:
|
||||
yield item.component
|
||||
|
||||
def add_item(self, component: "RenderableComponent") -> Self:
|
||||
self.items.append(ListItem(component=component))
|
||||
return self
|
||||
|
||||
def set_ordered(self, ordered: bool = True) -> Self:
|
||||
self.ordered = ordered
|
||||
return self
|
||||
|
||||
|
||||
class NotebookElement(BaseModel):
|
||||
"""一个 Notebook 页面中的单个元素"""
|
||||
|
||||
type: Literal[
|
||||
"heading",
|
||||
"paragraph",
|
||||
"image",
|
||||
"blockquote",
|
||||
"code",
|
||||
"list",
|
||||
"divider",
|
||||
"component",
|
||||
]
|
||||
"""元素类型"""
|
||||
text: str | None = None
|
||||
"""文本内容"""
|
||||
level: int | None = None
|
||||
"""标题级别"""
|
||||
src: str | None = None
|
||||
"""图片链接"""
|
||||
caption: str | None = None
|
||||
"""图片说明"""
|
||||
code: str | None = None
|
||||
"""代码块内容"""
|
||||
language: str | None = None
|
||||
"""代码语言"""
|
||||
data: list[str] | None = None
|
||||
"""列表数据"""
|
||||
ordered: bool | None = None
|
||||
"""是否为有序列表"""
|
||||
component: RenderableComponent | None = None
|
||||
"""可渲染组件"""
|
||||
|
||||
|
||||
class NotebookData(ContainerComponent):
|
||||
"""Notebook转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
elements: list[NotebookElement]
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/notebook"
|
||||
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
for element in self.elements:
|
||||
if element.component:
|
||||
yield element.component
|
||||
|
||||
def text(self, text: str) -> Self:
|
||||
self.elements.append(NotebookElement(type="paragraph", text=text))
|
||||
return self
|
||||
|
||||
def head(self, text: str, level: int = 1) -> Self:
|
||||
if not 1 <= level <= 4:
|
||||
raise ValueError("标题级别必须在1-4之间")
|
||||
self.elements.append(NotebookElement(type="heading", text=text, level=level))
|
||||
return self
|
||||
|
||||
def image(self, content: str | Path, caption: str | None = None) -> Self:
|
||||
src = ""
|
||||
if isinstance(content, Path):
|
||||
src = content.absolute().as_uri()
|
||||
elif content.startswith("base64"):
|
||||
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
else:
|
||||
src = content
|
||||
self.elements.append(NotebookElement(type="image", src=src, caption=caption))
|
||||
return self
|
||||
|
||||
def quote(self, text: str | list[str]) -> Self:
|
||||
if isinstance(text, str):
|
||||
self.elements.append(NotebookElement(type="blockquote", text=text))
|
||||
elif isinstance(text, list):
|
||||
for t in text:
|
||||
self.elements.append(NotebookElement(type="blockquote", text=t))
|
||||
return self
|
||||
|
||||
def code(self, code: str, language: str = "python") -> Self:
|
||||
self.elements.append(NotebookElement(type="code", code=code, language=language))
|
||||
return self
|
||||
|
||||
def list(self, items: list[str], ordered: bool = False) -> Self:
|
||||
self.elements.append(NotebookElement(type="list", data=items, ordered=ordered))
|
||||
return self
|
||||
|
||||
def add_divider(self) -> Self:
|
||||
self.elements.append(NotebookElement(type="divider"))
|
||||
return self
|
||||
|
||||
def add_component(self, component: "RenderableComponent") -> Self:
|
||||
self.elements.append(NotebookElement(type="component", component=component))
|
||||
return self
|
||||
|
||||
def add_texts(self, texts: builtins.list[str]) -> Self:
|
||||
for t in texts:
|
||||
self.text(t)
|
||||
return self
|
||||
|
||||
def add_quotes(self, quotes: builtins.list[str]) -> Self:
|
||||
for q in quotes:
|
||||
self.quote(q)
|
||||
return self
|
||||
@@ -0,0 +1,510 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.models.components.feedback import ProgressBar
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"CodeElement",
|
||||
"ComponentCell",
|
||||
"ComponentElement",
|
||||
"DetailsData",
|
||||
"DetailsItem",
|
||||
"HeadingElement",
|
||||
"ImageCell",
|
||||
"ImageElement",
|
||||
"ListElement",
|
||||
"ListItemElement",
|
||||
"MarkdownData",
|
||||
"MarkdownElement",
|
||||
"ProgressBarCell",
|
||||
"QuoteElement",
|
||||
"RawHtmlElement",
|
||||
"RichTextCell",
|
||||
"StatusBadgeCell",
|
||||
"TableCell",
|
||||
"TableData",
|
||||
"TableElement",
|
||||
"TextCell",
|
||||
"TextData",
|
||||
"TextElement",
|
||||
"TextSpan",
|
||||
]
|
||||
|
||||
|
||||
class TextSpan(BaseModel):
|
||||
"""单个富文本片段的数据模型"""
|
||||
|
||||
text: str
|
||||
"""文本内容"""
|
||||
bold: bool = False
|
||||
"""是否加粗"""
|
||||
italic: bool = False
|
||||
"""是否斜体"""
|
||||
underline: bool = False
|
||||
"""是否下划线"""
|
||||
strikethrough: bool = False
|
||||
"""是否删除线"""
|
||||
code: bool = False
|
||||
"""是否为等宽代码样式"""
|
||||
color: str | None = None
|
||||
"""文本颜色 (CSS color)"""
|
||||
font_size: str | None = None
|
||||
"""字体大小 (CSS font-size)"""
|
||||
font_family: str | None = None
|
||||
"""字体族 (CSS font-family)"""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
|
||||
self.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:
|
||||
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.spans.append(span)
|
||||
return self
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def add_item(self, label: str, value: Any) -> Self:
|
||||
self.items.append(DetailsItem(label=label, value=str(value)))
|
||||
return self
|
||||
|
||||
|
||||
class MarkdownElement(BaseModel, ABC):
|
||||
@abstractmethod
|
||||
def to_markdown(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class TextElement(MarkdownElement):
|
||||
type: Literal["text"] = "text"
|
||||
"""元素类型"""
|
||||
text: str
|
||||
"""文本内容"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return self.text
|
||||
|
||||
|
||||
class HeadingElement(MarkdownElement):
|
||||
type: Literal["heading"] = "heading"
|
||||
"""元素类型"""
|
||||
text: str
|
||||
"""标题文本"""
|
||||
level: int = Field(..., ge=1, le=6)
|
||||
"""标题级别 (1-6)"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f"{'#' * self.level} {self.text}"
|
||||
|
||||
|
||||
class ImageElement(MarkdownElement):
|
||||
type: Literal["image"] = "image"
|
||||
src: str
|
||||
alt: str = "image"
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f""
|
||||
|
||||
|
||||
class CodeElement(MarkdownElement):
|
||||
type: Literal["code"] = "code"
|
||||
code: str
|
||||
language: str = ""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f"```{self.language}\n{self.code}\n```"
|
||||
|
||||
|
||||
class RawHtmlElement(MarkdownElement):
|
||||
type: Literal["raw_html"] = "raw_html"
|
||||
html: str
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return self.html
|
||||
|
||||
|
||||
class TableElement(MarkdownElement):
|
||||
type: Literal["table"] = "table"
|
||||
headers: list[str]
|
||||
rows: list[list[str]]
|
||||
alignments: list[Literal["left", "center", "right"]] | None = None
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
header_row = "| " + " | ".join(self.headers) + " |"
|
||||
if self.alignments:
|
||||
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
|
||||
separator_row = (
|
||||
"| "
|
||||
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
|
||||
+ " |"
|
||||
)
|
||||
else:
|
||||
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
|
||||
data_rows = "\n".join(
|
||||
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
|
||||
)
|
||||
return f"{header_row}\n{separator_row}\n{data_rows}"
|
||||
|
||||
|
||||
class ContainerElement(MarkdownElement):
|
||||
content: list[MarkdownElement] = Field(default_factory=list)
|
||||
|
||||
|
||||
class QuoteElement(ContainerElement):
|
||||
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")])
|
||||
|
||||
|
||||
class ListItemElement(ContainerElement):
|
||||
def to_markdown(self) -> str:
|
||||
return "\n".join(part.to_markdown() for part in self.content)
|
||||
|
||||
|
||||
class ListElement(ContainerElement):
|
||||
type: Literal["list"] = "list"
|
||||
ordered: bool = False
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
lines = []
|
||||
for i, item in enumerate(self.content):
|
||||
if isinstance(item, ListItemElement):
|
||||
prefix = f"{i + 1}." if self.ordered else "*"
|
||||
item_content = item.to_markdown()
|
||||
lines.append(f"{prefix} {item_content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ComponentElement(MarkdownElement):
|
||||
type: Literal["component"] = "component"
|
||||
component: RenderableComponent
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class MarkdownData(ContainerComponent):
|
||||
"""
|
||||
Markdown组件数据模型。
|
||||
|
||||
支持链式调用构建内容,例如:
|
||||
ui.markdown("").text("hello").code("print(1)")
|
||||
"""
|
||||
|
||||
style_name: str | None = None
|
||||
elements: list[MarkdownElement] = Field(default_factory=list)
|
||||
"""Markdown元素列表"""
|
||||
width: int = 800
|
||||
"""渲染区域宽度"""
|
||||
css_path: str | None = None
|
||||
"""自定义CSS文件路径"""
|
||||
_context_stack: list[Any] = PrivateAttr(default_factory=list)
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/markdown"
|
||||
|
||||
async def get_extra_css(self, context: Any) -> str:
|
||||
css_parts = []
|
||||
if self.component_css:
|
||||
css_parts.append(self.component_css)
|
||||
|
||||
if self.css_path:
|
||||
css_file = Path(self.css_path)
|
||||
if await AsyncPath(css_file).is_file():
|
||||
async with aiofiles.open(css_file, encoding="utf-8") as f:
|
||||
css_parts.append(await f.read())
|
||||
else:
|
||||
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
|
||||
else:
|
||||
style_name = self.style_name or "light"
|
||||
css_path = await context.theme_manager.resolve_markdown_style_path(
|
||||
style_name, context
|
||||
)
|
||||
if css_path and css_path.exists():
|
||||
async with aiofiles.open(css_path, encoding="utf-8") as f:
|
||||
css_parts.append(await f.read())
|
||||
|
||||
return "\n".join(css_parts)
|
||||
|
||||
def set_width(self, width: int) -> Self:
|
||||
self.width = width
|
||||
return self
|
||||
|
||||
def set_css_path(self, css_path: str) -> Self:
|
||||
self.css_path = css_path
|
||||
return self
|
||||
|
||||
def _append_element(self, element: MarkdownElement) -> Self:
|
||||
if self._context_stack:
|
||||
self._context_stack[-1].content.append(element)
|
||||
else:
|
||||
self.elements.append(element)
|
||||
return self
|
||||
|
||||
def text(self, text: str) -> Self:
|
||||
return self._append_element(TextElement(text=text))
|
||||
|
||||
def head(self, text: str, level: int = 1) -> Self:
|
||||
return self._append_element(HeadingElement(text=text, level=level))
|
||||
|
||||
def image(self, content: str | Path, alt: str = "image") -> Self:
|
||||
src = ""
|
||||
if isinstance(content, Path):
|
||||
src = content.absolute().as_uri()
|
||||
elif content.startswith("base64://"):
|
||||
src = f"data:image/png;base64,{content.split('base64://', 1)[-1]}"
|
||||
else:
|
||||
src = content
|
||||
return self._append_element(ImageElement(src=src, alt=alt))
|
||||
|
||||
def code(self, code: str, language: str = "") -> Self:
|
||||
return self._append_element(CodeElement(code=code, language=language))
|
||||
|
||||
def table(
|
||||
self,
|
||||
headers: list[str],
|
||||
rows: list[list[str]],
|
||||
alignments: list[Any] | None = None,
|
||||
) -> Self:
|
||||
return self._append_element(
|
||||
TableElement(headers=headers, rows=rows, alignments=alignments)
|
||||
)
|
||||
|
||||
def add_divider(self) -> Self:
|
||||
return self._append_element(RawHtmlElement(html="---"))
|
||||
|
||||
def add_component(self, component: "RenderableComponent") -> Self:
|
||||
return self._append_element(ComponentElement(component=component))
|
||||
|
||||
class _ContextManager:
|
||||
def __init__(self, model: "MarkdownData", element: Any):
|
||||
self.model = model
|
||||
self.element = element
|
||||
|
||||
def __enter__(self):
|
||||
self.model._context_stack.append(self.element)
|
||||
return self.model
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.model._context_stack.pop()
|
||||
|
||||
def quote(self) -> AbstractContextManager["MarkdownData"]:
|
||||
element = QuoteElement()
|
||||
self._append_element(element)
|
||||
return self._ContextManager(self, element)
|
||||
|
||||
def list(self, ordered: bool = False) -> AbstractContextManager["MarkdownData"]:
|
||||
element = ListElement(ordered=ordered)
|
||||
self._append_element(element)
|
||||
return self._ContextManager(self, element)
|
||||
|
||||
def list_item(self) -> AbstractContextManager["MarkdownData"]:
|
||||
if not self._context_stack or not isinstance(
|
||||
self._context_stack[-1], ListElement
|
||||
):
|
||||
raise TypeError("list_item() 只能在 list() 上下文中使用。")
|
||||
element = ListItemElement()
|
||||
self._context_stack[-1].content.append(element)
|
||||
return self._ContextManager(self, element)
|
||||
|
||||
|
||||
class BaseCell(BaseModel):
|
||||
type: str
|
||||
|
||||
|
||||
class TextCell(BaseCell):
|
||||
type: Literal["text"] = "text" # type: ignore
|
||||
"""单元格类型"""
|
||||
content: str
|
||||
"""文本内容"""
|
||||
bold: bool = False
|
||||
"""是否加粗"""
|
||||
color: str | None = None
|
||||
"""文本颜色"""
|
||||
|
||||
|
||||
class ImageCell(BaseCell):
|
||||
type: Literal["image"] = "image" # type: ignore
|
||||
"""单元格类型"""
|
||||
src: str
|
||||
"""图片链接"""
|
||||
width: int = 40
|
||||
"""显示宽度"""
|
||||
height: int = 40
|
||||
"""显示高度"""
|
||||
shape: Literal["square", "circle"] = "square"
|
||||
"""图片形状"""
|
||||
alt: str = "image"
|
||||
"""替换文本"""
|
||||
|
||||
|
||||
class StatusBadgeCell(BaseCell):
|
||||
type: Literal["badge"] = "badge" # type: ignore
|
||||
"""单元格类型"""
|
||||
text: str
|
||||
"""徽章文本"""
|
||||
status_type: Literal["ok", "error", "warning", "info", "success"] = "info"
|
||||
"""状态类型,决定颜色"""
|
||||
|
||||
|
||||
class ProgressBarCell(BaseCell, ProgressBar):
|
||||
type: Literal["progress_bar"] = "progress_bar" # type: ignore
|
||||
|
||||
|
||||
class RichTextCell(BaseCell):
|
||||
type: Literal["rich_text"] = "rich_text" # type: ignore
|
||||
"""单元格类型"""
|
||||
spans: list[TextSpan] = Field(default_factory=list)
|
||||
"""富文本片段列表"""
|
||||
direction: Literal["column", "row"] = Field("column")
|
||||
"""排列方向"""
|
||||
gap: str = "4px"
|
||||
"""项目间距"""
|
||||
|
||||
|
||||
class ComponentCell(BaseCell):
|
||||
type: str = "component"
|
||||
component: RenderableComponent
|
||||
|
||||
|
||||
TableCell = (
|
||||
TextCell
|
||||
| ImageCell
|
||||
| StatusBadgeCell
|
||||
| ProgressBarCell
|
||||
| RichTextCell
|
||||
| ComponentCell
|
||||
| str
|
||||
| int
|
||||
| float
|
||||
| None
|
||||
)
|
||||
|
||||
|
||||
class TableData(RenderableComponent):
|
||||
style_name: str | None = None
|
||||
title: str
|
||||
"""表格标题"""
|
||||
tip: str | None = None
|
||||
"""标题旁的提示文本"""
|
||||
headers: list[str] = Field(default_factory=list)
|
||||
"""表格头字段列表"""
|
||||
rows: list[list[TableCell]] = Field(default_factory=list)
|
||||
"""数据行列表"""
|
||||
column_alignments: list[Literal["left", "center", "right"]] | None = None
|
||||
"""各列的对齐方式"""
|
||||
column_widths: list[str | int] | None = None
|
||||
"""各列的宽度限制"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/table"
|
||||
|
||||
def set_headers(self, headers: list[str]) -> Self:
|
||||
"""设置表格标题行"""
|
||||
self.headers = headers
|
||||
return self
|
||||
|
||||
def set_column_alignments(
|
||||
self, alignments: list[Literal["left", "center", "right"]]
|
||||
) -> Self:
|
||||
"""设置列对齐方式"""
|
||||
self.column_alignments = alignments
|
||||
return self
|
||||
|
||||
def set_column_widths(self, widths: list[str | int]) -> Self:
|
||||
"""设置列宽度"""
|
||||
self.column_widths = widths
|
||||
return self
|
||||
|
||||
def _normalize_cell(self, cell_data: Any) -> BaseCell:
|
||||
"""将任意数据标准化为 TableCell 类型"""
|
||||
if isinstance(cell_data, BaseCell):
|
||||
return cell_data
|
||||
if isinstance(cell_data, str | int | float):
|
||||
return TextCell(content=str(cell_data))
|
||||
if cell_data is None:
|
||||
return TextCell(content="")
|
||||
return TextCell(content=str(cell_data))
|
||||
|
||||
def add_row(self, row: list[Any]) -> Self:
|
||||
"""添加单行数据"""
|
||||
normalized_row = [self._normalize_cell(cell) for cell in row]
|
||||
self.rows.append(normalized_row) # type: ignore
|
||||
return self
|
||||
|
||||
def add_rows(self, rows: list[list[Any]]) -> Self:
|
||||
"""批量添加多行数据"""
|
||||
for row in rows:
|
||||
self.add_row(row)
|
||||
return self
|
||||
@@ -1,27 +0,0 @@
|
||||
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,58 +0,0 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = ["LayoutData", "LayoutItem"]
|
||||
|
||||
|
||||
class LayoutItem(BaseModel):
|
||||
"""布局中的单个项目,现在持有可渲染组件的数据模型"""
|
||||
|
||||
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
|
||||
"""要渲染的组件的数据模型"""
|
||||
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
|
||||
"""传递给模板的额外元数据"""
|
||||
|
||||
|
||||
class LayoutData(ContainerComponent):
|
||||
"""布局构建器的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
"""应用于布局容器的样式名称"""
|
||||
layout_type: str = "column"
|
||||
"""布局类型 (如 'column', 'row', 'grid')"""
|
||||
children: list[LayoutItem] = Field(
|
||||
default_factory=list, description="要布局的项目列表"
|
||||
)
|
||||
"""要布局的项目列表"""
|
||||
options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给模板的选项"
|
||||
)
|
||||
"""传递给模板的选项"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return f"components/core/layouts/{self.layout_type}"
|
||||
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
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,215 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiofiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = [
|
||||
"CodeElement",
|
||||
"ComponentElement",
|
||||
"HeadingElement",
|
||||
"ImageElement",
|
||||
"ListElement",
|
||||
"ListItemElement",
|
||||
"MarkdownData",
|
||||
"MarkdownElement",
|
||||
"QuoteElement",
|
||||
"RawHtmlElement",
|
||||
"TableElement",
|
||||
"TextElement",
|
||||
]
|
||||
|
||||
|
||||
class MarkdownElement(BaseModel, ABC):
|
||||
@abstractmethod
|
||||
def to_markdown(self) -> str:
|
||||
"""Serializes the element to its Markdown string representation."""
|
||||
pass
|
||||
|
||||
|
||||
class TextElement(MarkdownElement):
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return self.text
|
||||
|
||||
|
||||
class HeadingElement(MarkdownElement):
|
||||
type: Literal["heading"] = "heading"
|
||||
text: str
|
||||
"""标题文本"""
|
||||
level: int = Field(..., ge=1, le=6, description="标题级别 (1-6)")
|
||||
"""标题级别 (1-6)"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f"{'#' * self.level} {self.text}"
|
||||
|
||||
|
||||
class ImageElement(MarkdownElement):
|
||||
type: Literal["image"] = "image"
|
||||
src: str
|
||||
"""图片来源 (URL或data URI)"""
|
||||
alt: str = "image"
|
||||
"""图片的替代文本"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f""
|
||||
|
||||
|
||||
class CodeElement(MarkdownElement):
|
||||
type: Literal["code"] = "code"
|
||||
code: str
|
||||
"""代码字符串"""
|
||||
language: str = ""
|
||||
"""代码语言,用于语法高亮"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return f"```{self.language}\n{self.code}\n```"
|
||||
|
||||
|
||||
class RawHtmlElement(MarkdownElement):
|
||||
type: Literal["raw_html"] = "raw_html"
|
||||
html: str
|
||||
"""原始HTML字符串"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return self.html
|
||||
|
||||
|
||||
class TableElement(MarkdownElement):
|
||||
type: Literal["table"] = "table"
|
||||
headers: list[str]
|
||||
"""表格的表头列表"""
|
||||
rows: list[list[str]]
|
||||
"""表格的数据行列表"""
|
||||
alignments: list[Literal["left", "center", "right"]] | None = None
|
||||
"""每列的对齐方式"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
header_row = "| " + " | ".join(self.headers) + " |"
|
||||
|
||||
if self.alignments:
|
||||
align_map = {"left": ":---", "center": ":---:", "right": "---:"}
|
||||
separator_row = (
|
||||
"| "
|
||||
+ " | ".join([align_map.get(a, "---") for a in self.alignments])
|
||||
+ " |"
|
||||
)
|
||||
else:
|
||||
separator_row = "| " + " | ".join(["---"] * len(self.headers)) + " |"
|
||||
|
||||
data_rows = "\n".join(
|
||||
"| " + " | ".join(map(str, row)) + " |" for row in self.rows
|
||||
)
|
||||
return f"{header_row}\n{separator_row}\n{data_rows}"
|
||||
|
||||
|
||||
class ContainerElement(MarkdownElement):
|
||||
content: list[MarkdownElement] = Field(
|
||||
default_factory=list, description="容器内包含的Markdown元素列表"
|
||||
)
|
||||
"""容器内包含的Markdown元素列表"""
|
||||
|
||||
|
||||
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")])
|
||||
|
||||
|
||||
class ListItemElement(ContainerElement):
|
||||
def to_markdown(self) -> str:
|
||||
return "\n".join(part.to_markdown() for part in self.content)
|
||||
|
||||
|
||||
class ListElement(ContainerElement):
|
||||
type: Literal["list"] = "list"
|
||||
ordered: bool = False
|
||||
"""是否为有序列表 (例如 1., 2.)"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
lines = []
|
||||
for i, item in enumerate(self.content):
|
||||
if isinstance(item, ListItemElement):
|
||||
prefix = f"{i + 1}." if self.ordered else "*"
|
||||
item_content = item.to_markdown()
|
||||
lines.append(f"{prefix} {item_content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ComponentElement(MarkdownElement):
|
||||
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
|
||||
|
||||
type: Literal["component"] = "component"
|
||||
component: RenderableComponent
|
||||
"""嵌入在Markdown中的可渲染组件"""
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class MarkdownData(ContainerComponent):
|
||||
"""Markdown转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
"""Markdown内容的样式名称"""
|
||||
elements: list[MarkdownElement] = Field(
|
||||
default_factory=list, description="构成Markdown文档的元素列表"
|
||||
)
|
||||
"""构成Markdown文档的元素列表"""
|
||||
width: int = 800
|
||||
"""最终渲染图片的宽度"""
|
||||
css_path: str | None = None
|
||||
"""自定义CSS文件的绝对路径"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/markdown"
|
||||
|
||||
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:
|
||||
css_parts = []
|
||||
if self.component_css:
|
||||
css_parts.append(self.component_css)
|
||||
|
||||
if self.css_path:
|
||||
css_file = Path(self.css_path)
|
||||
if css_file.is_file():
|
||||
async with aiofiles.open(css_file, encoding="utf-8") as f:
|
||||
css_parts.append(await f.read())
|
||||
else:
|
||||
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
|
||||
else:
|
||||
style_name = self.style_name or "light"
|
||||
css_path = await context.theme_manager.resolve_markdown_style_path(
|
||||
style_name, context
|
||||
)
|
||||
if css_path and css_path.exists():
|
||||
async with aiofiles.open(css_path, encoding="utf-8") as f:
|
||||
css_parts.append(await f.read())
|
||||
|
||||
return "\n".join(css_parts)
|
||||
@@ -1,59 +0,0 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .base import ContainerComponent, RenderableComponent
|
||||
|
||||
__all__ = ["NotebookData", "NotebookElement"]
|
||||
|
||||
|
||||
class NotebookElement(BaseModel):
|
||||
"""一个 Notebook 页面中的单个元素"""
|
||||
|
||||
type: Literal[
|
||||
"heading",
|
||||
"paragraph",
|
||||
"image",
|
||||
"blockquote",
|
||||
"code",
|
||||
"list",
|
||||
"divider",
|
||||
"component",
|
||||
]
|
||||
text: str | None = None
|
||||
"""元素的文本内容 (用于标题、段落、引用)"""
|
||||
level: int | None = None
|
||||
"""标题的级别 (1-4)"""
|
||||
src: str | None = None
|
||||
"""图片的来源 (URL或data URI)"""
|
||||
caption: str | None = None
|
||||
"""图片的说明文字"""
|
||||
code: str | None = None
|
||||
"""代码块的内容"""
|
||||
language: str | None = None
|
||||
"""代码块的语言"""
|
||||
data: list[str] | None = None
|
||||
"""列表项的内容列表"""
|
||||
ordered: bool | None = None
|
||||
"""是否为有序列表"""
|
||||
component: RenderableComponent | None = None
|
||||
"""嵌入的自定义可渲染组件"""
|
||||
|
||||
|
||||
class NotebookData(ContainerComponent):
|
||||
"""Notebook转图片的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
"""Notebook的样式名称"""
|
||||
elements: list[NotebookElement]
|
||||
"""构成Notebook页面的元素列表"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/notebook"
|
||||
|
||||
def get_children(self) -> Iterable[RenderableComponent]:
|
||||
for element in self.elements:
|
||||
if element.type == "component" and element.component:
|
||||
yield element.component
|
||||
@@ -1,119 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...models.components.progress_bar import ProgressBar
|
||||
from .base import RenderableComponent
|
||||
from .text import TextSpan
|
||||
|
||||
__all__ = [
|
||||
"BaseCell",
|
||||
"ComponentCell",
|
||||
"ImageCell",
|
||||
"ProgressBarCell",
|
||||
"RichTextCell",
|
||||
"StatusBadgeCell",
|
||||
"TableCell",
|
||||
"TableData",
|
||||
"TextCell",
|
||||
]
|
||||
|
||||
|
||||
class BaseCell(BaseModel):
|
||||
"""单元格基础模型"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
class TextCell(BaseCell):
|
||||
"""文本单元格"""
|
||||
|
||||
type: Literal["text"] = "text" # type: ignore
|
||||
content: str
|
||||
bold: bool = False
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class ImageCell(BaseCell):
|
||||
"""图片单元格"""
|
||||
|
||||
type: Literal["image"] = "image" # type: ignore
|
||||
src: str
|
||||
width: int = 40
|
||||
height: int = 40
|
||||
shape: Literal["square", "circle"] = "square"
|
||||
alt: str = "image"
|
||||
|
||||
|
||||
class StatusBadgeCell(BaseCell):
|
||||
"""状态徽章单元格"""
|
||||
|
||||
type: Literal["badge"] = "badge" # type: ignore
|
||||
text: str
|
||||
status_type: Literal["ok", "error", "warning", "info"] = "info"
|
||||
|
||||
|
||||
class ProgressBarCell(BaseCell, ProgressBar):
|
||||
"""进度条单元格,继承ProgressBar模型以复用其字段"""
|
||||
|
||||
type: Literal["progress_bar"] = "progress_bar" # type: ignore
|
||||
|
||||
|
||||
class RichTextCell(BaseCell):
|
||||
"""富文本单元格,支持多个带样式的文本片段"""
|
||||
|
||||
type: Literal["rich_text"] = "rich_text" # type: ignore
|
||||
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
|
||||
"""文本片段列表"""
|
||||
direction: Literal["column", "row"] = Field("column", description="片段排列方向")
|
||||
"""片段排列方向"""
|
||||
gap: str = Field("4px", description="片段之间的间距")
|
||||
"""片段之间的间距"""
|
||||
|
||||
|
||||
class ComponentCell(BaseCell):
|
||||
"""一个通用的单元格,可以容纳任何可渲染的组件。"""
|
||||
|
||||
type: str = "component"
|
||||
component: RenderableComponent
|
||||
|
||||
|
||||
TableCell = (
|
||||
TextCell
|
||||
| ImageCell
|
||||
| StatusBadgeCell
|
||||
| ProgressBarCell
|
||||
| RichTextCell
|
||||
| ComponentCell
|
||||
| str
|
||||
| int
|
||||
| float
|
||||
| None
|
||||
)
|
||||
|
||||
|
||||
class TableData(RenderableComponent):
|
||||
"""通用表格的数据模型"""
|
||||
|
||||
style_name: str | None = None
|
||||
"""应用于表格容器的样式名称"""
|
||||
title: str = Field(..., description="表格主标题")
|
||||
"""表格主标题"""
|
||||
tip: str | None = Field(None, description="表格下方的提示信息")
|
||||
"""表格下方的提示信息"""
|
||||
headers: list[str] = Field(default_factory=list, description="表头列表")
|
||||
"""表头列表"""
|
||||
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
|
||||
"""数据行列表"""
|
||||
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])"
|
||||
)
|
||||
"""每列的宽度 (e.g., ['50px', 'auto', 100])"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
return "components/core/table"
|
||||
@@ -1,39 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .base import RenderableComponent
|
||||
|
||||
__all__ = ["TemplateComponent"]
|
||||
|
||||
|
||||
class TemplateComponent(RenderableComponent):
|
||||
"""基于独立模板文件的UI组件"""
|
||||
|
||||
_is_standalone_template: bool = True
|
||||
"""标记此组件为独立模板"""
|
||||
template_path: str | Path = Field(..., description="指向HTML模板文件的路径")
|
||||
"""指向HTML模板文件的路径"""
|
||||
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
|
||||
"""传递给模板的上下文数据字典"""
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
"""返回模板路径"""
|
||||
if isinstance(self.template_path, Path):
|
||||
return self.template_path.as_posix()
|
||||
return str(self.template_path)
|
||||
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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"
|
||||
@@ -0,0 +1,120 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, TypeVar
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.renderer.types import Renderable
|
||||
|
||||
T = TypeVar("T", bound=Renderable)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentEntry:
|
||||
"""组件注册条目,存储类与元数据的关联"""
|
||||
|
||||
component_class: type[Renderable]
|
||||
default_template: str | None
|
||||
|
||||
|
||||
class ComponentRegistry:
|
||||
"""
|
||||
UI 组件注册中心。
|
||||
负责管理组件类的索引,并自动处理模板命名空间的注册。
|
||||
"""
|
||||
|
||||
_instance: ClassVar = None
|
||||
_registry: ClassVar[dict[str, ComponentEntry]] = {}
|
||||
_class_template_map: ClassVar[dict[type[Renderable], str]] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
name: str,
|
||||
namespace: str = "core",
|
||||
template: str | None = None,
|
||||
template_root: Path | None = None,
|
||||
) -> Callable[[type[T]], type[T]]:
|
||||
"""
|
||||
装饰器:注册一个 UI 组件。
|
||||
|
||||
参数:
|
||||
name: 组件名称 (例如 'card')
|
||||
namespace: 命名空间 (例如 'core' 或插件名)
|
||||
template: (可选) 该组件默认绑定的模板路径 (例如 'components/card/main.html')
|
||||
template_root: (可选) 该组件对应的模板根目录。
|
||||
如果提供,会自动将其注册到 RendererService。
|
||||
"""
|
||||
|
||||
def wrapper(component_cls: type[T]) -> type[T]:
|
||||
full_key = f"{namespace}:{name}"
|
||||
|
||||
if full_key in cls._registry:
|
||||
logger.warning(f"UI组件 '{full_key}' 已被注册,将被覆盖。")
|
||||
|
||||
cls._registry[full_key] = ComponentEntry(
|
||||
component_class=component_cls, default_template=template
|
||||
)
|
||||
if template:
|
||||
cls._class_template_map[component_cls] = template
|
||||
|
||||
if template_root:
|
||||
if not template_root.exists():
|
||||
logger.warning(
|
||||
f"组件 '{full_key}' 提供的模板路径不存在: {template_root}"
|
||||
)
|
||||
else:
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
renderer_service.register_template_namespace(
|
||||
namespace, template_root
|
||||
)
|
||||
logger.debug(
|
||||
f"已自动注册组件模板空间: @{namespace} -> {template_root}"
|
||||
)
|
||||
|
||||
logger.trace(f"UI组件注册成功: {full_key} -> {component_cls.__name__}")
|
||||
return component_cls
|
||||
|
||||
return wrapper
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: str) -> type[Renderable] | None:
|
||||
"""根据 'namespace:name' 获取组件类。"""
|
||||
entry = cls._registry.get(key)
|
||||
return entry.component_class if entry else None
|
||||
|
||||
@classmethod
|
||||
def get_template_for_class(cls, component_cls: type[Renderable]) -> str | None:
|
||||
"""
|
||||
根据组件类查找注册时绑定的默认模板。
|
||||
支持子类继承查找 (可选,目前先做精确匹配)。
|
||||
"""
|
||||
return cls._class_template_map.get(component_cls)
|
||||
|
||||
@classmethod
|
||||
def create(cls, key: str, **kwargs) -> Renderable:
|
||||
"""
|
||||
动态工厂方法。
|
||||
|
||||
参数:
|
||||
key: 组件标识符 'namespace:name'
|
||||
**kwargs: 传递给组件模型的初始化参数
|
||||
"""
|
||||
component_cls = cls.get(key)
|
||||
if not component_cls:
|
||||
if ":" not in key:
|
||||
return cls.create(f"core:{key}", **kwargs)
|
||||
raise ValueError(f"未找到 UI 组件: {key}")
|
||||
|
||||
return component_cls(**kwargs) # type: ignore
|
||||
|
||||
|
||||
registry = ComponentRegistry()
|
||||
component = registry.register
|
||||
create = registry.create
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
import random
|
||||
import re
|
||||
from typing import overload
|
||||
|
||||
@@ -5,6 +7,7 @@ from nonebot.adapters import Bot
|
||||
from nonebot_plugin_uninfo import Session, SupportScope, Uninfo, get_interface
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import THEMES_PATH
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.services.cache.runtime_cache import (
|
||||
BanMemoryCache,
|
||||
@@ -95,6 +98,31 @@ class CommonUtils:
|
||||
elif isinstance(data, list):
|
||||
return "".join(cls.format(item) for item in data)
|
||||
|
||||
@staticmethod
|
||||
def get_random_asset_factory(sub_path: str) -> Callable[[], str | None]:
|
||||
"""
|
||||
创建一个从指定 assets 子目录随机选取资源的工厂函数。
|
||||
用于 Pydantic 模型的 default_factory。
|
||||
|
||||
参数:
|
||||
sub_path: 相对于 themes/default/assets/ 的子路径,例如 "ui/zhenxun/down"
|
||||
"""
|
||||
|
||||
def _factory() -> str | None:
|
||||
target_dir = THEMES_PATH / "default" / "assets" / sub_path
|
||||
if not target_dir.exists():
|
||||
return None
|
||||
|
||||
images = [
|
||||
f.name
|
||||
for f in target_dir.iterdir()
|
||||
if f.is_file()
|
||||
and f.suffix.lower() in [".png", ".jpg", ".jpeg", ".webp"]
|
||||
]
|
||||
return f"{sub_path}/{random.choice(images)}" if images else None
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
class SqlUtils:
|
||||
@classmethod
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import random
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.ui.builders import charts as chart_builders
|
||||
from zhenxun.ui.models.charts import EChartsData
|
||||
|
||||
from .models import Barh
|
||||
|
||||
@@ -21,11 +21,14 @@ class ChartUtils:
|
||||
if BACKGROUND_PATH.exists()
|
||||
else None
|
||||
)
|
||||
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(builder.build())
|
||||
items = list(zip(data.category_data, data.data))
|
||||
|
||||
chart_data = EChartsData.bar_chart(
|
||||
title=data.title,
|
||||
items=items, # type: ignore
|
||||
direction="horizontal",
|
||||
background_image=background_image_name,
|
||||
)
|
||||
|
||||
return await ui.render(chart_data)
|
||||
|
||||
@@ -46,6 +46,7 @@ def _sanitize_ui_html(html_string: str) -> str:
|
||||
"""
|
||||
专门用于净化UI渲染调试HTML的函数。
|
||||
它会查找所有内联的base64数据(如字体、图片)并将其截断。
|
||||
同时会折叠冗长的样式标签,避免主题 CSS 在日志中撑爆。
|
||||
"""
|
||||
if not isinstance(html_string, str):
|
||||
return html_string
|
||||
@@ -57,7 +58,30 @@ def _sanitize_ui_html(html_string: str) -> str:
|
||||
original_len = len(match.group(0)) - len(prefix)
|
||||
return f"{prefix}[...base64_omitted_len={original_len}...]"
|
||||
|
||||
return pattern.sub(replacer, html_string)
|
||||
html_string = pattern.sub(replacer, html_string)
|
||||
|
||||
pattern_style = re.compile(
|
||||
r"(<style[^>]*>)(.*?)(</style>)", re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
def replacer_style(match):
|
||||
start_tag, content, end_tag = match.group(1), match.group(2), match.group(3)
|
||||
keywords = [
|
||||
"Base Styles - Assembled by Jinja2",
|
||||
"Utility Classes",
|
||||
"@layer reset, base, components, utilities;",
|
||||
]
|
||||
|
||||
if len(content) > 600 or any(keyword in content for keyword in keywords):
|
||||
excerpt = (
|
||||
f"\n /* [theme.css.jinja content hidden for brevity - "
|
||||
f"{len(content)} chars] */\n"
|
||||
)
|
||||
return f"{start_tag}{excerpt}{end_tag}"
|
||||
|
||||
return match.group(0)
|
||||
|
||||
return pattern_style.sub(replacer_style, html_string)
|
||||
|
||||
|
||||
def _sanitize_nonebot_message(message: Message) -> Message:
|
||||
|
||||
@@ -436,6 +436,14 @@ class ZhenxunRepoManagerClass:
|
||||
branch: 分支名称
|
||||
force: 是否强制更新
|
||||
"""
|
||||
critical_dir = self.config.RESOURCE_PATH / "themes" / "default"
|
||||
if not critical_dir.exists() or not any(critical_dir.iterdir()):
|
||||
logger.warning(
|
||||
f"检测到关键资源目录 {critical_dir} 缺失或为空,将开启强制修复模式。",
|
||||
LOG_COMMAND,
|
||||
)
|
||||
force = True
|
||||
|
||||
if await check_git():
|
||||
await self.resources_git_update(source, branch, force)
|
||||
logger.debug("使用git更新资源文件!", LOG_COMMAND)
|
||||
|
||||
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
@@ -258,10 +259,10 @@ class BaseRepoManager(ABC):
|
||||
repo_url = prepare_repo_url(repo_url)
|
||||
|
||||
# 检查本地目录是否存在
|
||||
if not local_path.exists():
|
||||
if not await AsyncPath(local_path).exists():
|
||||
# 如果不存在,则克隆仓库
|
||||
logger.info(f"克隆仓库 {repo_url} 到 {local_path}", LOG_COMMAND)
|
||||
success, stdout, stderr = await run_git_command(
|
||||
success, _stdout, stderr = await run_git_command(
|
||||
f"clone -b {branch} {repo_url} {local_path}"
|
||||
)
|
||||
if not success:
|
||||
@@ -396,7 +397,7 @@ class BaseRepoManager(ABC):
|
||||
result.new_version = new_version.strip()
|
||||
|
||||
# 如果版本相同,则无需更新
|
||||
if old_version.strip() == new_version.strip():
|
||||
if old_version.strip() == new_version.strip() and not force:
|
||||
logger.info(
|
||||
f"仓库 {repo_url} 已是最新版本: {new_version.strip()}", LOG_COMMAND
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user