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 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}",

View File

@ -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,
)

View File

@ -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)

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_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:

View File

@ -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(

View File

@ -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)

View File

@ -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 = (

View File

@ -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",
]

View File

@ -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",

View File

@ -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
)