diff --git a/zhenxun/builtin_plugins/hooks/call_hook.py b/zhenxun/builtin_plugins/hooks/call_hook.py index 1695a48e..f87bd983 100644 --- a/zhenxun/builtin_plugins/hooks/call_hook.py +++ b/zhenxun/builtin_plugins/hooks/call_hook.py @@ -1,6 +1,7 @@ from typing import Any from nonebot.adapters import Bot, Message +from nonebot.adapters.onebot.v11 import MessageSegment from zhenxun.configs.config import Config from zhenxun.models.bot_message_store import BotMessageStore @@ -40,6 +41,35 @@ def replace_message(message: Message) -> str: 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 async def handle_api_result( 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), platform=PlatformUtils.get_platform(bot), ) - logger.debug(f"消息发送记录,message: {message}") + logger.debug(f"消息发送记录,message: {format_message_for_log(message)}") except Exception as e: logger.warning( f"消息发送记录发生错误...data: {data}, result: {result}", diff --git a/zhenxun/builtin_plugins/scheduler_admin/presenters.py b/zhenxun/builtin_plugins/scheduler_admin/presenters.py index e1a2715a..7005e348 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/presenters.py +++ b/zhenxun/builtin_plugins/scheduler_admin/presenters.py @@ -1,9 +1,11 @@ import asyncio from typing import Any +from zhenxun import ui from zhenxun.models.scheduled_job import ScheduledJob 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 @@ -118,19 +120,6 @@ def format_update_success(schedule_info: ScheduledJob) -> str: 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: """将任务参数格式化为人类可读的字符串""" 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) - def get_status_text(status_value): - if isinstance(status_value, bool): - return "启用" if status_value else "暂停" - return str(status_value) + data_list = [] + for s in all_statuses: + if not s: + continue - data_list = [ - [ - s["id"], - s["plugin_name"], - s.get("bot_id") or "N/A", - s["group_id"] or "全局", - s["next_run_time"], - _format_trigger_info(s), - _format_params(s), - get_status_text(s["is_enabled"]), - ] - for s in all_statuses - if s - ] + status_value = s["is_enabled"] + if status_value == "运行中": + status_cell = StatusBadgeCell(text="运行中", status_type="info") + else: + is_enabled = status_value == "启用" + status_cell = StatusBadgeCell( + text="启用" if is_enabled else "暂停", + status_type="ok" if is_enabled else "error", + ) + + data_list.append( + [ + 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: return "没有找到任何相关的定时任务。" - return await ImageTemplate.table_page( - head_text=title, - tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务", - column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"], - data_list=data_list, - column_space=20, - text_style=_status_row_style, + builder = TableBuilder( + title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务" + ) + builder.set_headers( + ["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"] + ).add_rows(data_list) + return await ui.render( + builder.build(), + viewport={"width": 1400, "height": 10}, + device_scale_factor=2, ) diff --git a/zhenxun/builtin_plugins/shop/__init__.py b/zhenxun/builtin_plugins/shop/__init__.py index 120d2198..d4ec3ee6 100644 --- a/zhenxun/builtin_plugins/shop/__init__.py +++ b/zhenxun/builtin_plugins/shop/__init__.py @@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()): nickname, 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) diff --git a/zhenxun/builtin_plugins/shop/_data_source.py b/zhenxun/builtin_plugins/shop/_data_source.py index ce57265c..f3583a7d 100644 --- a/zhenxun/builtin_plugins/shop/_data_source.py +++ b/zhenxun/builtin_plugins/shop/_data_source.py @@ -22,8 +22,8 @@ from zhenxun.models.user_console import UserConsole from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_props_log import UserPropsLog from zhenxun.services.log import logger +from zhenxun.ui.models import ImageCell, TextCell from zhenxun.utils.enum import GoldHandle, PropHandle -from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.pydantic_compat import model_dump @@ -92,9 +92,7 @@ class ShopParam(BaseModel): return model_dump(self, **kwargs) -async def gold_rank( - session: Uninfo, group_id: str | None, num: int -) -> BuildImage | str: +async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str: query = UserConsole if group_id: uid_list = await GroupInfoUser.filter(group_id=group_id).values_list( @@ -125,16 +123,18 @@ async def gold_rank( data_list = [] platform = PlatformUtils.get_platform(session) for i, user in enumerate(user_list): - ava_bytes = await PlatformUtils.get_user_avatar( - user[0], platform, session.self_id - ) + ava_url = PlatformUtils.get_user_avatar_url(user[0], platform, session.self_id) data_list.append( [ - f"{i + 1}", - (ava_bytes, 30, 30) if platform == "qq" else "", - uid2name.get(user[0]), - user[1], - (PLATFORM_PATH.get(platform), 30, 30), + TextCell(content=f"{i + 1}"), + ImageCell(src=ava_url or "", shape="circle") + if platform == "qq" + else TextCell(content=""), + 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: @@ -143,7 +143,11 @@ async def gold_rank( else: title = "金币全局排行" 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: @@ -493,7 +497,7 @@ class ShopManage: @classmethod async def my_props( cls, user_id: str, name: str, platform: str | None = None - ) -> BuildImage | None: + ) -> bytes | None: """获取道具背包 参数: @@ -544,12 +548,11 @@ class ShopManage: return None column_name = ["-", "使用ID", "名称", "数量", "简介"] - return await ImageTemplate.table_page( - f"{name}的道具仓库", - "通过 使用道具[ID/名称] 令道具生效", - column_name, - table_rows, - ) + 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()) @classmethod async def my_cost(cls, user_id: str, platform: str | None = None) -> int: diff --git a/zhenxun/builtin_plugins/sign_in/_data_source.py b/zhenxun/builtin_plugins/sign_in/_data_source.py index 607d135b..ec64083b 100644 --- a/zhenxun/builtin_plugins/sign_in/_data_source.py +++ b/zhenxun/builtin_plugins/sign_in/_data_source.py @@ -6,6 +6,7 @@ import secrets from nonebot_plugin_uninfo import Uninfo import pytz +from zhenxun import ui from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.models.friend_user import FriendUser 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.user_console import UserConsole 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 ._random_event import random_event @@ -33,7 +34,7 @@ class SignManage: @classmethod async def rank( 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 返回: - BuildImage: 构造图片 + bytes: 构造图片 """ query = SignUser if group_id: @@ -78,17 +79,21 @@ class SignManage: data_list = [] platform = PlatformUtils.get_platform(session) 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 ) data_list.append( [ - f"{i + 1}", - (bytes, 30, 30) if user[3] == "qq" else "", - uid2name.get(user[0]), - user[1], - user[2], - (PLATFORM_PATH.get(user[3]), 30, 30), + TextCell(content=f"{i + 1}"), + ImageCell(src=ava_url or "", shape="circle") + if user[3] == "qq" + else TextCell(content=""), + TextCell(content=uid2name.get(user[0]) or user[0]), + 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: @@ -97,7 +102,11 @@ class SignManage: else: title = "好感度全局排行" 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 async def sign( diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py index 8ad4523c..2a78aa68 100644 --- a/zhenxun/builtin_plugins/sign_in/utils.py +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -227,8 +227,6 @@ async def _generate_html_card( "current": impression, "level": level, "level_text": f"{level} [{lik2relation.get(str(level), '未知')}]", - "attitude": f"对你的态度: {level2attitude.get(str(level), '未知')}", - "relation": lik2relation.get(str(level), "未知"), "heart2": [1 for _ in range(level)], "heart1": [1 for _ in range(len(lik2level) - level - 1)], "next_level_at": next_impression, @@ -238,7 +236,6 @@ async def _generate_html_card( reward_info = None rank = None total_gold = None - last_sign_date_str = None if is_card_view: 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 total_gold = user_console.gold if user_console else 0 - last_sign_date_str = "" - reward_info = { - "impression": f"好感度排名第 {rank} 位", - "gold": f"总金币:{total_gold}", - "gift": "", + "impression_added": 0, + "gold_added": 0, + "gift_received": "", "is_double": False, } @@ -285,7 +280,6 @@ async def _generate_html_card( "progress": progress, "rank": rank, "total_gold": total_gold, - "last_sign_date_str": last_sign_date_str, } image_bytes = await ui.render_template("pages/builtin/sign", data=card_data) diff --git a/zhenxun/services/renderer/theme.py b/zhenxun/services/renderer/theme.py index b386e4c6..1740739b 100644 --- a/zhenxun/services/renderer/theme.py +++ b/zhenxun/services/renderer/theme.py @@ -56,6 +56,138 @@ class Theme(BaseModel): 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: def __init__(self, env: Environment): """ @@ -83,120 +215,31 @@ class ThemeManager: return [] 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 的目录。 - 这被认为是组件的根目录。如果找不到,则返回起始路径的目录。 - """ - 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: (可选) 当渲染独立模板时,提供模板所在的目录。 + 创建一个闭包函数 (Jinja2中的 `asset()` 函数),使用 + ResourceResolver 进行路径解析。 """ + resolver = ResourceResolver(self) @pass_context def asset_loader(ctx, asset_path: str) -> str: - if asset_path.startswith("./"): - parent_template_name = ctx.environment.get_template(ctx.name).name - parent_template_abs_path = Path( - ctx.environment.loader.get_source( - ctx.environment, parent_template_name - )[1] - ) - - if ( - "/skins/" in parent_template_abs_path.as_posix() - or "\\skins\\" in parent_template_abs_path.as_posix() - ): - skin_dir = parent_template_abs_path.parent - skin_asset_path = skin_dir / "assets" / asset_path[2:] - if skin_asset_path.exists(): - logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'") - return skin_asset_path.absolute().as_uri() - logger.debug( - f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源" - ) - - component_root = self._find_component_root(parent_template_abs_path) - - local_asset = component_root / "assets" / asset_path[2:] - if local_asset.exists(): - logger.debug(f"找到组件公共资源: '{local_asset}'") - return local_asset.absolute().as_uri() - - logger.warning( - f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。" - ) - return "" - - assert self.current_theme is not None - current_theme_asset = self.current_theme.assets_dir / asset_path - if current_theme_asset.exists(): - return current_theme_asset.absolute().as_uri() - - default_theme_asset = self.current_theme.default_assets_dir / asset_path - if default_theme_asset.exists(): - return default_theme_asset.absolute().as_uri() - - logger.warning( - f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: " - f"{asset_path}" - ) - return "" + if not ctx.name: + logger.warning("Jinja2 上下文缺少模板名称,无法进行资源解析。") + return resolver.resolve_asset_uri(asset_path, "unknown_template") + parent_template_name = ctx.name + return resolver.resolve_asset_uri(asset_path, parent_template_name) return asset_loader def _create_standalone_asset_loader( self, local_base_path: Path ) -> Callable[[str], str]: - """ - [新增] 为独立模板创建一个专用的、更简单的 asset loader。 - """ + """为独立模板创建一个专用的 asset loader。""" + resolver = ResourceResolver(self) def asset_loader(asset_path: str) -> str: - if asset_path.startswith("./"): - local_file = local_base_path / "assets" / asset_path[2:] - if local_file.exists(): - return local_file.absolute().as_uri() - logger.warning( - f"独立模板本地资源 '{asset_path}' 在 " - f"'{local_base_path / 'assets'}' 中未找到。" - ) - return "" - - assert self.current_theme is not None - current_theme_asset = self.current_theme.assets_dir / asset_path - if current_theme_asset.exists(): - return current_theme_asset.absolute().as_uri() - - default_theme_asset = self.current_theme.default_assets_dir / asset_path - if default_theme_asset.exists(): - return default_theme_asset.absolute().as_uri() - - logger.warning( - f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: " - f"{asset_path}" - ) - return "" + return resolver.resolve_asset_uri(asset_path, str(local_base_path)) return asset_loader @@ -278,15 +321,7 @@ class ThemeManager: new_theme_loader = FileSystemLoader( [str(theme_dir), str(THEMES_PATH / "default")] ) - self.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader]) - else: - self.jinja_env.loader = FileSystemLoader( - [str(theme_dir), str(THEMES_PATH / "default")] - ) - else: - self.jinja_env.loader = FileSystemLoader( - [str(theme_dir), str(THEMES_PATH / "default")] - ) + self.jinja_env.loader.loaders = [prefix_loader, new_theme_loader] palette_path = theme_dir / "palette.json" palette = ( diff --git a/zhenxun/ui/models/__init__.py b/zhenxun/ui/models/__init__.py index 280f0519..34fb418c 100644 --- a/zhenxun/ui/models/__init__.py +++ b/zhenxun/ui/models/__init__.py @@ -26,12 +26,14 @@ from .core import ( QuoteElement, RawHtmlElement, RenderableComponent, + RichTextCell, StatusBadgeCell, TableCell, TableData, TableElement, TextCell, TextElement, + TextSpan, ) from .presets import ( HelpCategory, @@ -71,11 +73,13 @@ __all__ = [ "RawHtmlElement", "Rectangle", "RenderableComponent", + "RichTextCell", "StatusBadgeCell", "TableCell", "TableData", "TableElement", "TextCell", "TextElement", + "TextSpan", "UserInfoBlock", ] diff --git a/zhenxun/ui/models/core/__init__.py b/zhenxun/ui/models/core/__init__.py index 513e902e..e19ed058 100644 --- a/zhenxun/ui/models/core/__init__.py +++ b/zhenxun/ui/models/core/__init__.py @@ -22,7 +22,15 @@ from .markdown import ( TextElement, ) 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 .text import TextData, TextSpan @@ -48,6 +56,7 @@ __all__ = [ "QuoteElement", "RawHtmlElement", "RenderableComponent", + "RichTextCell", "StatusBadgeCell", "TableCell", "TableData", diff --git a/zhenxun/ui/models/core/table.py b/zhenxun/ui/models/core/table.py index c905256a..15724bef 100644 --- a/zhenxun/ui/models/core/table.py +++ b/zhenxun/ui/models/core/table.py @@ -4,11 +4,13 @@ from pydantic import BaseModel, Field from ...models.components.progress_bar import ProgressBar from .base import RenderableComponent +from .text import TextSpan __all__ = [ "BaseCell", "ImageCell", "ProgressBarCell", + "RichTextCell", "StatusBadgeCell", "TableCell", "TableData", @@ -56,8 +58,25 @@ 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, description="文本片段列表") + direction: Literal["column", "row"] = Field("column", description="片段排列方向") + gap: str = Field("4px", description="片段之间的间距") + + TableCell = ( - TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None + TextCell + | ImageCell + | StatusBadgeCell + | ProgressBarCell + | RichTextCell + | str + | int + | float + | None )