feat(ui): 添加富文本单元格并迁移UI表格渲染 (#2039)

*  feat(ui): 添加富文本单元格并迁移UI表格渲染

- 【新功能】
  - 添加 `RichTextCell` 模型,支持在表格单元格中显示多个带样式的文本片段。
  - `TableCell` 类型别名更新以包含 `RichTextCell`。
- 【迁移】
  - 将`ShopManage`、`SignManage` 和 `SchedulerManager` 中所有基于 `ImageTemplate.table_page` 的表格图片生成逻辑迁移至新的 `TableBuilder` 和 `ui.render` 系统。
  - 移除旧的 `ImageTemplate` 导入和 `RowStyle` 函数。
  - 将 `ThemeManager` 中的资源解析逻辑提取到独立的 `ResourceResolver` 类中,增强模块化和可维护性。
  - 优化 `ThemeManager.load_theme` 中 `ChoiceLoader` 的处理逻辑。
  - 优化签到卡片数据结构,移除 `last_sign_date_str` 字段,并调整 `reward_info` 在卡片视图下的结构。
  - 移除 `_generate_html_card` 中 `favorability_info` 的 `attitude` 和 `relation` 字段。

* 🎨 (log): 优化消息日志格式,摘要base64内容

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio 2025-08-30 18:13:37 +08:00 committed by GitHub
parent b505307f2f
commit 7f460296dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 296 additions and 193 deletions

View File

@ -1,6 +1,7 @@
from typing import Any from typing import Any
from nonebot.adapters import Bot, Message from nonebot.adapters import Bot, Message
from nonebot.adapters.onebot.v11 import MessageSegment
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.models.bot_message_store import BotMessageStore from zhenxun.models.bot_message_store import BotMessageStore
@ -40,6 +41,35 @@ def replace_message(message: Message) -> str:
return result return result
def format_message_for_log(message: Message) -> str:
"""
将消息对象转换为适合日志记录的字符串对base64等长内容进行摘要处理
"""
if not isinstance(message, Message):
return str(message)
log_parts = []
for seg in message:
seg: MessageSegment
if seg.type == "text":
log_parts.append(seg.data.get("text", ""))
elif seg.type in ("image", "record", "video"):
file_info = seg.data.get("file", "")
if isinstance(file_info, str) and file_info.startswith("base64://"):
b64_data = file_info[9:]
data_size_bytes = (len(b64_data) * 3) / 4 - b64_data.count("=", -2)
log_parts.append(
f"[{seg.type}: base64, size={data_size_bytes / 1024:.2f}KB]"
)
else:
log_parts.append(f"[{seg.type}]")
elif seg.type == "at":
log_parts.append(f"[@{seg.data.get('qq', 'unknown')}]")
else:
log_parts.append(f"[{seg.type}]")
return "".join(log_parts)
@Bot.on_called_api @Bot.on_called_api
async def handle_api_result( async def handle_api_result(
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
@ -78,7 +108,7 @@ async def handle_api_result(
else replace_message(message), else replace_message(message),
platform=PlatformUtils.get_platform(bot), platform=PlatformUtils.get_platform(bot),
) )
logger.debug(f"消息发送记录message: {message}") logger.debug(f"消息发送记录message: {format_message_for_log(message)}")
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"消息发送记录发生错误...data: {data}, result: {result}", f"消息发送记录发生错误...data: {data}, result: {result}",

View File

@ -1,9 +1,11 @@
import asyncio import asyncio
from typing import Any from typing import Any
from zhenxun import ui
from zhenxun.models.scheduled_job import ScheduledJob from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services.scheduler import scheduler_manager from zhenxun.services.scheduler import scheduler_manager
from zhenxun.utils._image_template import ImageTemplate, RowStyle from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.pydantic_compat import model_json_schema from zhenxun.utils.pydantic_compat import model_json_schema
@ -118,19 +120,6 @@ def format_update_success(schedule_info: ScheduledJob) -> str:
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info) return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
def _status_row_style(column: str, text: str) -> RowStyle:
"""为状态列设置颜色"""
style = RowStyle()
if column == "状态":
if text == "启用":
style.font_color = "#67C23A"
elif text == "暂停":
style.font_color = "#F56C6C"
elif text == "运行中":
style.font_color = "#409EFF"
return style
def _format_params(schedule_status: dict) -> str: def _format_params(schedule_status: dict) -> str:
"""将任务参数格式化为人类可读的字符串""" """将任务参数格式化为人类可读的字符串"""
if kwargs := schedule_status.get("job_kwargs"): if kwargs := schedule_status.get("job_kwargs"):
@ -157,36 +146,47 @@ async def format_schedule_list_as_image(
] ]
all_statuses = await asyncio.gather(*status_tasks) all_statuses = await asyncio.gather(*status_tasks)
def get_status_text(status_value): data_list = []
if isinstance(status_value, bool): for s in all_statuses:
return "启用" if status_value else "暂停" if not s:
return str(status_value) continue
data_list = [ status_value = s["is_enabled"]
[ if status_value == "运行中":
s["id"], status_cell = StatusBadgeCell(text="运行中", status_type="info")
s["plugin_name"], else:
s.get("bot_id") or "N/A", is_enabled = status_value == "启用"
s["group_id"] or "全局", status_cell = StatusBadgeCell(
s["next_run_time"], text="启用" if is_enabled else "暂停",
_format_trigger_info(s), status_type="ok" if is_enabled else "error",
_format_params(s), )
get_status_text(s["is_enabled"]),
] data_list.append(
for s in all_statuses [
if s TextCell(content=str(s["id"])),
] TextCell(content=s["plugin_name"]),
TextCell(content=s.get("bot_id") or "N/A"),
TextCell(content=s["group_id"] or "全局"),
TextCell(content=s["next_run_time"]),
TextCell(content=_format_trigger_info(s)),
TextCell(content=_format_params(s)),
status_cell,
]
)
if not data_list: if not data_list:
return "没有找到任何相关的定时任务。" return "没有找到任何相关的定时任务。"
return await ImageTemplate.table_page( builder = TableBuilder(
head_text=title, title, f"{current_page}/{total_pages} 页,共 {total_items} 条任务"
tip_text=f"{current_page}/{total_pages} 页,共 {total_items} 条任务", )
column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"], builder.set_headers(
data_list=data_list, ["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
column_space=20, ).add_rows(data_list)
text_style=_status_row_style, return await ui.render(
builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
) )

View File

@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()):
nickname, nickname,
PlatformUtils.get_platform(session), PlatformUtils.get_platform(session),
): ):
await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True) await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True) # type: ignore
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True) return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)

View File

@ -22,8 +22,8 @@ from zhenxun.models.user_console import UserConsole
from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.models.user_props_log import UserPropsLog from zhenxun.models.user_props_log import UserPropsLog
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import GoldHandle, PropHandle from zhenxun.utils.enum import GoldHandle, PropHandle
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump from zhenxun.utils.pydantic_compat import model_dump
@ -92,9 +92,7 @@ class ShopParam(BaseModel):
return model_dump(self, **kwargs) return model_dump(self, **kwargs)
async def gold_rank( async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
session: Uninfo, group_id: str | None, num: int
) -> BuildImage | str:
query = UserConsole query = UserConsole
if group_id: if group_id:
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list( uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
@ -125,16 +123,18 @@ async def gold_rank(
data_list = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): for i, user in enumerate(user_list):
ava_bytes = await PlatformUtils.get_user_avatar( ava_url = PlatformUtils.get_user_avatar_url(user[0], platform, session.self_id)
user[0], platform, session.self_id
)
data_list.append( data_list.append(
[ [
f"{i + 1}", TextCell(content=f"{i + 1}"),
(ava_bytes, 30, 30) if platform == "qq" else "", ImageCell(src=ava_url or "", shape="circle")
uid2name.get(user[0]), if platform == "qq"
user[1], else TextCell(content=""),
(PLATFORM_PATH.get(platform), 30, 30), TextCell(content=uid2name.get(user[0]) or user[0]),
TextCell(content=str(user[1]), bold=True),
ImageCell(src=platform_path.resolve().as_uri())
if (platform_path := PLATFORM_PATH.get(platform))
else TextCell(content=""),
] ]
) )
if group_id: if group_id:
@ -143,7 +143,11 @@ async def gold_rank(
else: else:
title = "金币全局排行" title = "金币全局排行"
tip = f"你的排名在全局第 {index} 位哦!" tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list) 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())
class ShopManage: class ShopManage:
@ -493,7 +497,7 @@ class ShopManage:
@classmethod @classmethod
async def my_props( async def my_props(
cls, user_id: str, name: str, platform: str | None = None cls, user_id: str, name: str, platform: str | None = None
) -> BuildImage | None: ) -> bytes | None:
"""获取道具背包 """获取道具背包
参数: 参数:
@ -544,12 +548,11 @@ class ShopManage:
return None return None
column_name = ["-", "使用ID", "名称", "数量", "简介"] column_name = ["-", "使用ID", "名称", "数量", "简介"]
return await ImageTemplate.table_page( from zhenxun.ui.builders import TableBuilder
f"{name}的道具仓库",
"通过 使用道具[ID/名称] 令道具生效", builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
column_name, builder.set_headers(column_name).add_rows(table_rows)
table_rows, return await ui.render(builder.build())
)
@classmethod @classmethod
async def my_cost(cls, user_id: str, platform: str | None = None) -> int: async def my_cost(cls, user_id: str, platform: str | None = None) -> int:

View File

@ -6,6 +6,7 @@ import secrets
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
import pytz import pytz
from zhenxun import ui
from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.friend_user import FriendUser from zhenxun.models.friend_user import FriendUser
from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.group_member_info import GroupInfoUser
@ -13,7 +14,7 @@ from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from ._random_event import random_event from ._random_event import random_event
@ -33,7 +34,7 @@ class SignManage:
@classmethod @classmethod
async def rank( async def rank(
cls, session: Uninfo, num: int, group_id: str | None = None cls, session: Uninfo, num: int, group_id: str | None = None
) -> BuildImage | str: # sourcery skip: avoid-builtin-shadow ) -> bytes | str:
"""好感度排行 """好感度排行
参数: 参数:
@ -42,7 +43,7 @@ class SignManage:
group_id: 群组id group_id: 群组id
返回: 返回:
BuildImage: 构造图片 bytes: 构造图片
""" """
query = SignUser query = SignUser
if group_id: if group_id:
@ -78,17 +79,21 @@ class SignManage:
data_list = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): for i, user in enumerate(user_list):
bytes = await PlatformUtils.get_user_avatar( ava_url = PlatformUtils.get_user_avatar_url(
user[0], platform, session.self_id user[0], platform, session.self_id
) )
data_list.append( data_list.append(
[ [
f"{i + 1}", TextCell(content=f"{i + 1}"),
(bytes, 30, 30) if user[3] == "qq" else "", ImageCell(src=ava_url or "", shape="circle")
uid2name.get(user[0]), if user[3] == "qq"
user[1], else TextCell(content=""),
user[2], TextCell(content=uid2name.get(user[0]) or user[0]),
(PLATFORM_PATH.get(user[3]), 30, 30), TextCell(content=str(user[1]), bold=True),
TextCell(content=str(user[2])),
ImageCell(src=platform_path.resolve().as_uri())
if (platform_path := PLATFORM_PATH.get(platform))
else TextCell(content=""),
] ]
) )
if group_id: if group_id:
@ -97,7 +102,11 @@ class SignManage:
else: else:
title = "好感度全局排行" title = "好感度全局排行"
tip = f"你的排名在全局第 {index} 位哦!" tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list) 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())
@classmethod @classmethod
async def sign( async def sign(

View File

@ -227,8 +227,6 @@ async def _generate_html_card(
"current": impression, "current": impression,
"level": level, "level": level,
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]", "level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
"attitude": f"对你的态度: {level2attitude.get(str(level), '未知')}",
"relation": lik2relation.get(str(level), "未知"),
"heart2": [1 for _ in range(level)], "heart2": [1 for _ in range(level)],
"heart1": [1 for _ in range(len(lik2level) - level - 1)], "heart1": [1 for _ in range(len(lik2level) - level - 1)],
"next_level_at": next_impression, "next_level_at": next_impression,
@ -238,7 +236,6 @@ async def _generate_html_card(
reward_info = None reward_info = None
rank = None rank = None
total_gold = None total_gold = None
last_sign_date_str = None
if is_card_view: if is_card_view:
value_list = ( value_list = (
@ -249,12 +246,10 @@ async def _generate_html_card(
rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0 rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0
total_gold = user_console.gold if user_console else 0 total_gold = user_console.gold if user_console else 0
last_sign_date_str = ""
reward_info = { reward_info = {
"impression": f"好感度排名第 {rank}", "impression_added": 0,
"gold": f"总金币:{total_gold}", "gold_added": 0,
"gift": "", "gift_received": "",
"is_double": False, "is_double": False,
} }
@ -285,7 +280,6 @@ async def _generate_html_card(
"progress": progress, "progress": progress,
"rank": rank, "rank": rank,
"total_gold": total_gold, "total_gold": total_gold,
"last_sign_date_str": last_sign_date_str,
} }
image_bytes = await ui.render_template("pages/builtin/sign", data=card_data) image_bytes = await ui.render_template("pages/builtin/sign", data=card_data)

View File

@ -56,6 +56,138 @@ class Theme(BaseModel):
default_assets_dir: Path default_assets_dir: Path
class ResourceResolver:
"""
一个独立的用于解析组件和主题资源的类
封装了所有复杂的路径查找和回退逻辑
资源解析遵循以下回退顺序以支持强大的主题覆盖和组件化:
1. **相对路径 (`./`)**: 对于在模板中使用 `asset('./style.css')` 的情况
这是组件内部的资源
a. **皮肤资源**: 首先在当前组件的皮肤目录中查找
(`.../skins/{variant_name}/assets/`)
这允许皮肤完全覆盖其组件的默认资源
b. **当前主题组件资源**: 接着在当前激活主题的组件根目录中查找
(`.../{theme_name}/.../assets/`)
c. **默认主题组件资源**: 如果仍未找到最后回退到 `default` 主题中
对应的组件目录
(`.../default/.../assets/`) 查找这是核心的回退逻辑
2. **全局路径**: 对于使用 `asset('js/script.js')` 的情况这是主题的全局资源
a. **当前主题全局资源**: 在当前激活主题的根 `assets` 目录中查找
(`themes/{theme_name}/assets/`)
b. **默认主题全局资源**: 如果找不到则回退到 `default` 主题的根 `assets` 目录
(`themes/default/assets/`)
"""
def __init__(self, theme_manager: "ThemeManager"):
self.theme_manager = theme_manager
def _find_component_root(self, start_path: Path) -> Path:
"""从给定路径向上查找,找到包含 manifest.json 的组件根目录。"""
current_path = start_path.parent
themes_root_parts = len(THEMES_PATH.parts)
for _ in range(len(current_path.parts) - themes_root_parts):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _search_paths_for_relative_asset(
self, asset_path: str, parent_template_name: str
) -> list[tuple[str, Path]]:
"""为相对路径的资源生成所有可能的查找路径元组 (描述, 路径)。"""
if not self.theme_manager.current_theme:
return []
paths_to_check: list[tuple[str, Path]] = []
current_theme_name = self.theme_manager.current_theme.name
current_theme_root = self.theme_manager.current_theme.assets_dir.parent
default_theme_root = self.theme_manager.current_theme.default_assets_dir.parent
if not self.theme_manager.jinja_env.loader:
return []
source_info = self.theme_manager.jinja_env.loader.get_source(
self.theme_manager.jinja_env, parent_template_name
)
if not source_info[1]:
return []
parent_template_abs_path = Path(source_info[1])
component_logical_root = Path(parent_template_name).parent
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
paths_to_check.append(
(
f"'{current_theme_name}' 主题皮肤资源",
skin_dir / "assets" / asset_path,
)
)
paths_to_check.append(
(
f"'{current_theme_name}' 主题组件资源",
current_theme_root / component_logical_root / "assets" / asset_path,
)
)
if current_theme_name != "default":
paths_to_check.append(
(
"'default' 主题组件资源 (回退)",
default_theme_root / component_logical_root / "assets" / asset_path,
)
)
return paths_to_check
def resolve_asset_uri(self, asset_path: str, current_template_name: str) -> str:
"""解析资源路径实现完整的回退逻辑并返回可用的URI。"""
if not self.theme_manager.current_theme:
return ""
search_paths: list[tuple[str, Path]] = []
if asset_path.startswith("./"):
search_paths.extend(
self._search_paths_for_relative_asset(
asset_path[2:], current_template_name
)
)
else:
search_paths.append(
(
f"'{self.theme_manager.current_theme.name}' 主题全局资源",
self.theme_manager.current_theme.assets_dir / asset_path,
)
)
if self.theme_manager.current_theme.name != "default":
search_paths.append(
(
"'default' 主题全局资源 (回退)",
self.theme_manager.current_theme.default_assets_dir
/ asset_path,
)
)
for source_desc, path in search_paths:
if path.exists():
logger.debug(f"解析资源 '{asset_path}' -> 找到 {source_desc}: '{path}'")
return path.absolute().as_uri()
logger.warning(
f"资源文件未找到: '{asset_path}' (在模板 '{current_template_name}' 中引用)"
)
return ""
class ThemeManager: class ThemeManager:
def __init__(self, env: Environment): def __init__(self, env: Environment):
""" """
@ -83,120 +215,31 @@ class ThemeManager:
return [] return []
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()] return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
def _find_component_root(self, start_path: Path) -> Path: def _create_asset_loader(self) -> Callable[..., str]:
""" """
从给定的起始路径向上查找直到找到包含 manifest.json 的目录 创建一个闭包函数 (Jinja2中的 `asset()` 函数)使用
这被认为是组件的根目录如果找不到则返回起始路径的目录 ResourceResolver 进行路径解析
"""
current_path = start_path.parent
for _ in range(len(current_path.parts)):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _create_asset_loader(
self, local_base_path: Path | None = None
) -> Callable[..., str]:
"""
创建并返回一个用于解析静态资源的闭包函数 (Jinja2中的 `asset()` 函数)
该函数实现了强大的资源解析回退逻辑查找顺序如下:
1. **相对路径 (`./`)**: 优先查找相对于当前模板的 `assets` 目录
- 这支持组件皮肤 (`skins/`) 对其资源的覆盖
2. **当前主题**: 在当前激活主题的 `assets` 目录中查找
3. **默认主题**: 如果当前主题未找到则回退到 `default` 主题的 `assets` 目录
参数:
local_base_path: (可选) 当渲染独立模板时提供模板所在的目录
""" """
resolver = ResourceResolver(self)
@pass_context @pass_context
def asset_loader(ctx, asset_path: str) -> str: def asset_loader(ctx, asset_path: str) -> str:
if asset_path.startswith("./"): if not ctx.name:
parent_template_name = ctx.environment.get_template(ctx.name).name logger.warning("Jinja2 上下文缺少模板名称,无法进行资源解析。")
parent_template_abs_path = Path( return resolver.resolve_asset_uri(asset_path, "unknown_template")
ctx.environment.loader.get_source( parent_template_name = ctx.name
ctx.environment, parent_template_name return resolver.resolve_asset_uri(asset_path, parent_template_name)
)[1]
)
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
skin_asset_path = skin_dir / "assets" / asset_path[2:]
if skin_asset_path.exists():
logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'")
return skin_asset_path.absolute().as_uri()
logger.debug(
f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源"
)
component_root = self._find_component_root(parent_template_abs_path)
local_asset = component_root / "assets" / asset_path[2:]
if local_asset.exists():
logger.debug(f"找到组件公共资源: '{local_asset}'")
return local_asset.absolute().as_uri()
logger.warning(
f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader return asset_loader
def _create_standalone_asset_loader( def _create_standalone_asset_loader(
self, local_base_path: Path self, local_base_path: Path
) -> Callable[[str], str]: ) -> Callable[[str], str]:
""" """为独立模板创建一个专用的 asset loader。"""
[新增] 为独立模板创建一个专用的更简单的 asset loader resolver = ResourceResolver(self)
"""
def asset_loader(asset_path: str) -> str: def asset_loader(asset_path: str) -> str:
if asset_path.startswith("./"): return resolver.resolve_asset_uri(asset_path, str(local_base_path))
local_file = local_base_path / "assets" / asset_path[2:]
if local_file.exists():
return local_file.absolute().as_uri()
logger.warning(
f"独立模板本地资源 '{asset_path}'"
f"'{local_base_path / 'assets'}' 中未找到。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}''default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader return asset_loader
@ -278,15 +321,7 @@ class ThemeManager:
new_theme_loader = FileSystemLoader( new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")] [str(theme_dir), str(THEMES_PATH / "default")]
) )
self.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader]) self.jinja_env.loader.loaders = [prefix_loader, new_theme_loader]
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
palette_path = theme_dir / "palette.json" palette_path = theme_dir / "palette.json"
palette = ( palette = (

View File

@ -26,12 +26,14 @@ from .core import (
QuoteElement, QuoteElement,
RawHtmlElement, RawHtmlElement,
RenderableComponent, RenderableComponent,
RichTextCell,
StatusBadgeCell, StatusBadgeCell,
TableCell, TableCell,
TableData, TableData,
TableElement, TableElement,
TextCell, TextCell,
TextElement, TextElement,
TextSpan,
) )
from .presets import ( from .presets import (
HelpCategory, HelpCategory,
@ -71,11 +73,13 @@ __all__ = [
"RawHtmlElement", "RawHtmlElement",
"Rectangle", "Rectangle",
"RenderableComponent", "RenderableComponent",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",
"TableElement", "TableElement",
"TextCell", "TextCell",
"TextElement", "TextElement",
"TextSpan",
"UserInfoBlock", "UserInfoBlock",
] ]

View File

@ -22,7 +22,15 @@ from .markdown import (
TextElement, TextElement,
) )
from .notebook import NotebookData, NotebookElement from .notebook import NotebookData, NotebookElement
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell from .table import (
BaseCell,
ImageCell,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TextCell,
)
from .template import TemplateComponent from .template import TemplateComponent
from .text import TextData, TextSpan from .text import TextData, TextSpan
@ -48,6 +56,7 @@ __all__ = [
"QuoteElement", "QuoteElement",
"RawHtmlElement", "RawHtmlElement",
"RenderableComponent", "RenderableComponent",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",

View File

@ -4,11 +4,13 @@ from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent from .base import RenderableComponent
from .text import TextSpan
__all__ = [ __all__ = [
"BaseCell", "BaseCell",
"ImageCell", "ImageCell",
"ProgressBarCell", "ProgressBarCell",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",
@ -56,8 +58,25 @@ class ProgressBarCell(BaseCell, ProgressBar):
type: Literal["progress_bar"] = "progress_bar" # type: ignore 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="片段之间的间距")
TableCell = ( TableCell = (
TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| str
| int
| float
| None
) )