mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 21:52:56 +08:00
✨ 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:
parent
b505307f2f
commit
7f460296dd
@ -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}",
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user