Compare commits

...
Author SHA1 Message Date
HibiKier e08d89350f ✨ feat(help): 添加帮助功能的快捷方式支持
- 在帮助插件中新增了对“帮助”命令的快捷方式支持,允许用户通过简化的输入方式获取功能信息。
- 移除了不必要的别名,优化了命令的可用性。
2025-11-03 10:50:45 +08:00
Rumioandwebjoin111 07be73c1b7 ✨ feat(avatar): 引入头像缓存服务并优化头像获取 (#2055)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
Co-authored-by: webjoin111 <455457521@qq.com>
2025-09-28 08:53:10 +08:00
molanp 7e6896fa01 🚑fix(data_source): 修复插件商店更新路径错误 (#2056)
* 🚑fix(data_source): 修复插件商店更新路径错误

* fix(plugin_store): 修复插件模块路径处理逻辑

简化了插件模块路径的赋值逻辑,直接使用插件对象的模块路径,避免不必要的路径分割操作。
同时修复了目标目录判断条件,确保只有在模块路径为根目录时才使用插件名称作为目录。
2025-09-28 08:50:54 +08:00
Rumioandwebjoin111 3cc882b116 ✨ feat(auto_update): 增强自动更新与版本检查 (#2042)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
- 优化 `检查更新` 默认行为,未指定类型时直接显示版本信息
- 扩展版本详情显示:当前版本、最新开发版/正式版(含日期)、资源版本及更新提示
- 新增更新后资源兼容性检查,自动读取 `resources.spec` 并提示更新
- 使用 `asyncio.gather` 并发获取版本信息,引入 `packaging` 库提高比较准确性
- 优化错误处理与日志记录

Co-authored-by: webjoin111 <455457521@qq.com>
2025-09-12 17:38:41 +08:00
molanpandHibiKier ee699fb345 fix(plugin_store): 修复插件商店的安装与卸载逻辑 (#2050)
* fix(plugin_store): 修复插件商店的安装与卸载逻辑

- 优化了插件安装、更新和移除的逻辑
- 调整了插件路径的处理方式,支持更灵活的安装位置
- 重构了 `install_plugin_with_repo` 方法,使用 `StorePluginInfo` 对象作为参数
- 修复了一些潜在的路径问题和模块命名问题

* refactor(zhenxun): 优化插件信息获取逻辑

- 将 PluginInfo.get_or_none 替换为 get_plugin 方法,简化插件信息获取逻辑
- 优化了插件移除操作中的插件信息获取流程

* refactor(zhenxun): 优化 sparse_checkout_clone 函数的实现

- 将 git 操作移至临时目录中执行,避免影响目标目录中的现有内容
- 简化了稀疏检出的配置和执行过程
- 改进了错误处理和回退逻辑
- 优化了文件移动和目录清理的操作

* 🐛 添加移除插件时二次查询

* ✨ plugin_info.get_plugin参数包含plugin_type时无效过滤

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-09-12 17:38:24 +08:00
molanp 631e66d54f fix(htmlrender): 更新htmlrender 导入 路径 (#2051)
- 将 get_browser 的导入路径从 nonebot_plugin_htmlrender 更新为 nonebot_plugin_htmlrender.browser
2025-09-12 16:41:43 +08:00
c7ef6fdb17 ✨ feat(ui): 增强表格构建器并完善组件模型文档 (#2048)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
* ✨ feat(table): 添加 ComponentCell 以支持表格单元格中嵌入可渲染组件

* ✨ feat(ui): 增强表格构建器并完善组件模型文档

- 增强 `TableBuilder`,新增 `_normalize_cell` 辅助方法,支持自动将原生数据类型(如 `str`, `int`, `Path`)转换为 `TableCell` 模型,简化了表格行的创建。
- 完善 `zhenxun/ui/models` 目录下所有组件模型字段的 `description` 属性和文档字符串,显著提升了代码可读性和开发者体验。
- 优化 `shop/_data_source.py` 中 `gold_rank` 函数的平台路径判断格式,并统一 `my_props` 函数中图标路径的处理逻辑。

* 🚨 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>
2025-09-11 10:31:49 +08:00
molanp fb0a9813e1 fix(ui): 修复表格组件中对本地图片的显示问题 (#2047)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
- 在 ImageCell 中添加对 Path 类型的支持,并在验证器中处理路径解析
- 优化 ShopManage 和 SignManage 类中的代码,使用新的 ImageCell 构造方式
- 更新 TableData 类中的注释,提高代码可读性
2025-09-09 15:01:45 +08:00
molanp 6940c2f37b 🚑 修复 我的道具 渲染异常 (#2046)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-09-08 08:43:56 +08:00
molanp 74ce848127 修复对 Uninfo 错误的版本限制 (#2041)
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-09-01 17:20:24 +08:00
molanp 9e5c4aa3e7 build(deps): 更新项目依赖项 (#2038)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
- 移除了大量不再需要的依赖项
- 更新了部分依赖项的版本,指定了版本范围
- 添加了少数新的依赖项
2025-08-30 18:13:56 +08:00
7f460296dd ✨ 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>
2025-08-30 18:13:37 +08:00
HibiKier b505307f2f 🐛 优化CacheDict类中的键存在性检查和获取逻辑,简化代码结构,提高可读性。 (#2037)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-08-29 16:07:04 +08:00
molanp 4ab9382205 build(deps): 更新依赖版本 (#2034) 2025-08-29 15:08:17 +08:00
HibiKier 1e2aa99207 Bugfix/fix aliyun (#2036)
* 🐛 修复数据库超时问题

* 🔧 移除帮助图片清理功能.

* ✨ 更新插件商店功能,允许在添加插件时指定源类型为 None。优化插件 ID 查找逻辑,增强代码可读性。新增 zhenxun/ui 模块导入。

* 🔧 优化数据访问和数据库上下文逻辑,移除不必要的全局变量和日志信息,调整日志级别为调试,提升代码可读性和性能。

* ✨ 增强插件商店功能,支持在下载文件时指定稀疏检出路径和目标目录。优化二进制文件处理逻辑,提升文件下载的准确性和效率。

* ✨ 增强阿里云和GitHub的文件管理功能,新增Git不可用异常处理,优化稀疏检出逻辑,提升代码可读性和稳定性。

* ✨ 增强插件下载功能,新增对下载结果的异常处理,确保在Git不可用时抛出相应异常信息。优化错误提示,提升用户体验。

* ✨ 增强插件商店功能,优化添加插件时的提示信息,明确区分插件模块和名称。新增 Windows 下删除只读文件的处理逻辑,提升插件管理的稳定性和用户体验。

* ✨ 优化文件内容获取逻辑,新增对非二进制文件的UTF-8解码处理,提升文件读取的稳定性和准确性。
2025-08-29 14:57:08 +08:00
7472cabd48 ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离 (#2035)
* ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离

🏗️ **架构重构**
- 移除charts.py中所有硬编码样式参数(grid、tooltip、legend等)
- 将样式配置迁移至主题层style.json文件
- 统一图表模板消费样式文件的能力

📊 **图表组件优化**
- bar_chart: 移除grid和坐标轴show参数
- pie_chart: 移除tooltip、legend样式和series视觉参数
- line_chart: 移除tooltip、grid和坐标轴配置
- radar_chart: 移除tooltip硬编码

🎨 **主题系统增强**
- 新增pie_chart、line_chart、radar_chart的style.json配置
- 更新bar_chart/style.json,添加grid、xAxis、yAxis样式
- 所有图表模板支持deepMerge样式合并逻辑

🔧 **Breaking Changes**
- 图表工厂函数不再接受样式参数
- 主题开发者现可通过style.json完全定制图表外观
- 提升组件可维护性和主题灵活性

* 📦️ build(pyinstaller): 引入 resources.spec 并更新 .gitignore 规则

* 🚨 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>
2025-08-28 09:20:15 +08:00
HibiKier d9e65057cf ⚡ 减少数据库查询次数,提高数据库查询方面性能 (#2030)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
* 🐛 修复数据库超时问题

* 🔧 移除帮助图片清理功能.

* ✨ 更新插件商店功能,允许在添加插件时指定源类型为 None。优化插件 ID 查找逻辑,增强代码可读性。新增 zhenxun/ui 模块导入。

* 🔧 优化数据访问和数据库上下文逻辑,移除不必要的全局变量和日志信息,调整日志级别为调试,提升代码可读性和性能。
2025-08-26 16:53:14 +08:00
molanp b12168b6b9 refactor(zhenxun): 移除失效的 GitHub 代理 (#2029)
- 从 get_fastest_raw_formats、get_fastest_archive_formats 和 get_fastest_release_formats 函数中移除了 ghproxy.cc 代理地址
2025-08-26 16:48:02 +08:00
HibiKier a63f26c3b6 ✨ 增强插件商店功能,支持添加插件时指定源类型。 (#2028)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-08-21 11:08:34 +08:00
HibiKier 095a123c3c 🔧 移除帮助图片删除功能,简化插件切换逻辑。更新相关导入,优化代码结构。 (#2027)
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
2025-08-20 21:18:57 +08:00
HibiKier f9a38a26b2 🐛 修复群组申请通知 (#2026)
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
* ✨ 修复一些bug

- 移除不必要的定时器类,简化代码结构
- 优化好友请求处理逻辑,确保在自动同意和手动处理之间的清晰区分
- 更新缓存机制,避免重复处理相同的好友请求
- 新增判断文件是否为二进制文件的功能,提升文件处理的准确性
- 优化缓存字典的过期检查逻辑,提高性能和可读性

* ✨ 更新 get_async_client 函数,支持字符串类型的代理参数

- 修改 proxies 参数类型,允许传入字符串形式的代理地址
- 增强代理处理逻辑,将字符串代理转换为字典格式,提升灵活性和可用性
2025-08-19 16:20:52 +08:00
111 changed files with 6987 additions and 4219 deletions
+1
View File
@@ -32,6 +32,7 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
!resources.spec
# Installer logs
pip-log.txt
+1036 -941
View File
File diff suppressed because it is too large Load Diff
+17 -13
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
bilireq = ">=0.2.10"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = "^0.12.2"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
pydantic = "1.10.18"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
pydantic = ">=1.0.0, <2.0.0"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
alibabacloud-devops20210625 = "^5.0.2"
[tool.poetry.group.dev.dependencies]
@@ -57,7 +62,6 @@ respx = "^0.21.1"
ruff = "^0.8.0"
pre-commit = "^4.0.0"
[tool.nonebot]
plugins = [
"nonebot_plugin_apscheduler",
+1112 -1004
View File
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
bilireq = ">=0.2.10"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = "^0.12.2"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
pydantic = "2.10.6"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
pydantic = ">=2.0.0, <3.0.0"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
alibabacloud-devops20210625 = "^5.0.2"
[tool.poetry.group.dev.dependencies]
Generated
+1156 -921
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,14 +36,14 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
bilireq = ">=0.2.10"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
@@ -145,4 +145,4 @@ asyncio_default_fixture_loop_scope = "session"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
build-backend = "poetry.core.masonry.api"
+37 -131
View File
@@ -1,131 +1,37 @@
aiocache==0.12.3 ; python_version >= "3.10" and python_version < "4.0"
aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0"
aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
alibabacloud-devops20210625==5.0.2 ; python_version >= "3.10" and python_version < "4.0"
anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0"
apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0"
arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0"
arclet-alconna==1.8.35 ; python_version >= "3.10" and python_version < "4.0"
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
async-timeout==5.0.1 ; python_version == "3.10"
asyncpg==0.30.0 ; python_version >= "3.10" and python_version < "4.0"
attrs==25.1.0 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.13.3 ; python_version >= "3.10" and python_version < "4.0"
bilireq==0.2.3.post0 ; python_version >= "3.10" and python_version < "4.0"
binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0"
cashews==7.4.0 ; python_version >= "3.10" and python_version < "4.0"
cattrs==23.2.3 ; python_version >= "3.10" and python_version < "4.0"
certifi==2025.1.31 ; python_version >= "3.10" and python_version < "4.0"
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.8 ; python_version >= "3.10" and python_version < "4.0"
cn2an==0.5.23 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32")
cookiecutter==2.6.0 ; python_version >= "3.10" and python_version < "4.0"
cryptography==44.0.1 ; python_version >= "3.10" and python_version < "4.0"
dateparser==1.2.1 ; python_version >= "3.10" and python_version < "4.0"
distlib==0.3.9 ; python_version >= "3.10" and python_version < "4.0"
ecdsa==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "4.0"
fastapi==0.115.8 ; python_version >= "3.10" and python_version < "4.0"
feedparser==6.0.11 ; python_version >= "3.10" and python_version < "4.0"
filelock==3.17.0 ; python_version >= "3.10" and python_version < "4.0"
greenlet==3.1.1 ; python_version >= "3.10" and python_version < "4.0"
grpcio==1.70.0 ; python_version >= "3.10" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
httpcore==0.16.3 ; python_version >= "3.10" and python_version < "4.0"
httptools==0.6.4 ; python_version >= "3.10" and python_version < "4.0"
httpx==0.23.3 ; python_version >= "3.10" and python_version < "4.0"
idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
imagehash==4.3.2 ; python_version >= "3.10" and python_version < "4.0"
importlib-metadata==8.6.1 ; python_version >= "3.10" and python_version < "4.0"
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.5 ; python_version >= "3.10" and python_version < "4.0"
loguru==0.7.3 ; python_version >= "3.10" and python_version < "4.0"
lxml==5.3.1 ; python_version >= "3.10" and python_version < "4.0"
markdown-it-py==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.7 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==3.0.2 ; python_version >= "3.10" and python_version < "4.0"
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0"
msgpack==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
multidict==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
nb-cli==1.4.2 ; python_version >= "3.10" and python_version < "4.0"
nepattern==0.7.7 ; python_version >= "3.10" and python_version < "4.0"
nonebot-adapter-onebot==2.4.6 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-alconna==0.54.2 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-apscheduler==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-htmlrender==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-session==0.2.3 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-uninfo==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-waiter==0.8.1 ; python_version >= "3.10" and python_version < "4.0"
nonebot2==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
nonebot2[fastapi]==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
noneprompt==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
numpy==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
pillow==10.4.0 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0"
playwright==1.50.0 ; python_version >= "3.10" and python_version < "4.0"
proces==0.1.7 ; python_version >= "3.10" and python_version < "4.0"
prompt-toolkit==3.0.50 ; python_version >= "3.10" and python_version < "4.0"
propcache==0.2.1 ; python_version >= "3.10" and python_version < "4.0"
protobuf==4.25.6 ; python_version >= "3.10" and python_version < "4.0"
psutil==5.9.8 ; python_version >= "3.10" and python_version < "4.0"
py-cpuinfo==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
pyasn1==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
pydantic-core==2.27.2 ; python_version >= "3.10" and python_version < "4.0"
pydantic==2.10.6 ; python_version >= "3.10" and python_version < "4.0"
pyee==12.1.1 ; python_version >= "3.10" and python_version < "4.0"
pyfiglet==1.0.2 ; python_version >= "3.10" and python_version < "4.0"
pygments==2.19.1 ; python_version >= "3.10" and python_version < "4.0"
pygtrie==2.5.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.14.3 ; python_version >= "3.10" and python_version < "4.0"
pypika-tortoise==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
pypinyin==0.51.0 ; python_version >= "3.10" and python_version < "4"
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
python-jose[cryptography]==3.3.0 ; python_version >= "3.10" and python_version < "4.0"
python-markdown-math==0.8 ; python_version >= "3.10" and python_version < "4.0"
python-multipart==0.0.9 ; python_version >= "3.10" and python_version < "4.0"
python-slugify==8.0.4 ; python_version >= "3.10" and python_version < "4.0"
pytz==2025.1 ; python_version >= "3.10" and python_version < "4.0"
pywavelets==1.8.0 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0"
regex==2024.11.6 ; python_version >= "3.10" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
retrying==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
rich==13.9.4 ; python_version >= "3.10" and python_version < "4.0"
rsa==4.9 ; python_version >= "3.10" and python_version < "4"
ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_version < "3.13" and python_version >= "3.10"
ruamel-yaml==0.18.10 ; python_version >= "3.10" and python_version < "4.0"
scipy==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
sgmllib3k==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
six==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
soupsieve==2.6 ; python_version >= "3.10" and python_version < "4.0"
starlette==0.45.3 ; python_version >= "3.10" and python_version < "4.0"
strenum==0.4.15 ; python_version >= "3.10" and python_version < "4.0"
tarina==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
tenacity==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
text-unidecode==1.3 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.2.1 ; python_version == "3.10"
tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0"
tortoise-orm[asyncpg]==0.20.0 ; python_version >= "3.10" and python_version < "4.0"
types-python-dateutil==2.9.0.20241206 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
tzdata==2025.1 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
tzlocal==5.2 ; python_version >= "3.10" and python_version < "4.0"
ujson==5.10.0 ; python_version >= "3.10" and python_version < "4.0"
urllib3==2.3.0 ; python_version >= "3.10" and python_version < "4.0"
uvicorn[standard]==0.34.0 ; python_version >= "3.10" and python_version < "4.0"
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.10" and python_version < "4.0"
virtualenv==20.29.2 ; python_version >= "3.10" and python_version < "4.0"
watchfiles==0.24.0 ; python_version >= "3.10" and python_version < "4.0"
wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0"
websockets==14.2 ; python_version >= "3.10" and python_version < "4.0"
win32-setctime==1.2.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
yarl==1.18.3 ; python_version >= "3.10" and python_version < "4.0"
zipp==3.21.0 ; python_version >= "3.10" and python_version < "4.0"
playwright>=1.41.1,<2.0.0
nonebot-adapter-onebot>=2.3.1
nonebot-plugin-apscheduler>=0.5,<0.6
tortoise-orm>=0.20.0,<0.21.0
cattrs>=23.2.3,<24.0.0
ruamel.yaml>=0.18.5,<0.19.0
strenum>=0.4.15,<0.5.0
nonebot-plugin-session>=0.3.2,<0.4.0
ujson>=5.9.0
nb-cli>=1.3.0
nonebot2[fastapi]>=2.3.3
Pillow>=10.0.0,<11.0.0
retrying>=1.3.4,<2.0.0
aiofiles>=23.2.1,<24.0.0
nonebot-plugin-htmlrender>=0.6.0,<1.0.0
pypinyin>=0.51.0
beautifulsoup4>=4.12.3,<5.0.0
lxml>=5.1.0,<6.0.0
psutil>=5.9.8,<6.0.0
feedparser>=6.0.11,<7.0.0
ImageHash>=4.3.1,<5.0.0
cn2an>=0.5.22,<0.6.0
dateparser>=1.2.0,<2.0.0
bilireq>=0.2.10
python-jose[cryptography]>=3.3.0,<4.0.0
python-multipart>=0.0.9,<0.1.0
aiocache[redis]>=0.12.3,<0.13.0
py-cpuinfo>=9.0.0,<10.0.0
nonebot-plugin-alconna>=0.56.0
tenacity>=9.0.0,<10.0.0
nonebot-plugin-uninfo>=0.7.3
nonebot-plugin-waiter>=0.8.1,<0.9.0
multidict>=6.0.0,<7.0.0,!=6.3.2
alibabacloud-devops20210625>=5.0.2,<6.0.0
redis>=5
asyncpg>=0.20.0
+1
View File
@@ -0,0 +1 @@
require_resources_version: ">=1.0.0"
@@ -9,7 +9,7 @@ from zhenxun.services.log import logger
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.message import MessageUtils
from ._data_source import PluginManager, build_plugin, build_task, delete_help_image
from ._data_source import PluginManager, build_plugin, build_task
from .command import _group_status_matcher, _status_matcher
base_config = Config.get("plugin_switch")
@@ -154,7 +154,6 @@ async def _(
else:
result = await PluginManager.unblock_group_plugin(name, group_id)
logger.info(f"开启功能 {name}", arparma.header_result, session=session)
delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True)
elif session.user.id in bot.config.superusers:
"""私聊"""
@@ -218,7 +217,6 @@ async def _(
session=session,
target=group_id,
)
delete_help_image()
await MessageUtils.build_message(result).finish(reply_to=True)
@@ -266,7 +264,6 @@ async def _(
else:
result = await PluginManager.block_group_plugin(name, group_id)
logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True)
elif session.user.id in bot.config.superusers:
group_id = group.result if group.available else None
@@ -338,7 +335,6 @@ async def _(
session=session,
target=group_id,
)
delete_help_image()
await MessageUtils.build_message(result).finish(reply_to=True)
@@ -1,7 +1,5 @@
import os
from typing import cast
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
@@ -11,23 +9,6 @@ from zhenxun.utils.enum import BlockType, CacheType, PluginType
from zhenxun.utils.exception import GroupInfoNotFound
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
HELP_FILE = IMAGE_PATH / "SIMPLE_HELP.png"
GROUP_HELP_PATH = DATA_PATH / "group_help"
def delete_help_image(gid: str | None = None):
"""删除帮助图片"""
if gid:
for file in os.listdir(GROUP_HELP_PATH):
if file.startswith(f"{gid}"):
os.remove(GROUP_HELP_PATH / file)
else:
if HELP_FILE.exists():
HELP_FILE.unlink()
for file in GROUP_HELP_PATH.iterdir():
file.unlink()
def plugin_row_style(column: str, text: str) -> RowStyle:
"""被动技能文本风格
@@ -84,13 +84,16 @@ async def _(
):
result = ""
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
if not ver_type.available:
result += await UpdateManager.check_version()
logger.info("查看当前版本...", "检查更新", session=session)
await MessageUtils.build_message(result).finish()
return
ver_type_str = ver_type.result
source_str = source.result
if ver_type_str in {"main", "release"}:
if not ver_type.available:
result += await UpdateManager.check_version()
logger.info("查看当前版本...", "检查更新", session=session)
await MessageUtils.build_message(result).finish()
try:
result += await UpdateManager.update_zhenxun(
bot,
@@ -1,37 +1,135 @@
import asyncio
from typing import Literal
from nonebot.adapters import Bot
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from zhenxun.services.log import logger
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.manager.zhenxun_repo_manager import (
ZhenxunRepoConfig,
ZhenxunRepoManager,
)
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.repo_utils import RepoFileManager
LOG_COMMAND = "AutoUpdate"
class UpdateManager:
@staticmethod
async def _get_latest_commit_date(owner: str, repo: str, path: str) -> str:
"""获取文件最新 commit 日期"""
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits"
params = {"path": path, "page": 1, "per_page": 1}
try:
data = await AsyncHttpx.get_json(api_url, params=params)
if data and isinstance(data, list) and data[0]:
date_str = data[0]["commit"]["committer"]["date"]
return date_str.split("T")[0]
except Exception as e:
logger.warning(f"获取 {owner}/{repo}/{path} 的 commit 日期失败", e=e)
return "获取失败"
@classmethod
async def check_version(cls) -> str:
"""检查更新版本
"""检查真寻和资源的版本"""
bot_cur_version = cls.__get_version()
返回:
str: 更新信息
"""
cur_version = cls.__get_version()
release_data = await ZhenxunRepoManager.zhenxun_get_latest_releases_data()
if not release_data:
return "检查更新获取版本失败..."
return (
"检测到当前版本更新\n"
f"当前版本:{cur_version}\n"
f"最新版本:{release_data.get('name')}\n"
f"创建日期:{release_data.get('created_at')}\n"
f"更新内容:\n{release_data.get('body')}"
release_task = ZhenxunRepoManager.zhenxun_get_latest_releases_data()
dev_version_task = RepoFileManager.get_file_content(
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "__version__"
)
bot_commit_date_task = cls._get_latest_commit_date(
"HibiKier", "zhenxun_bot", "__version__"
)
res_commit_date_task = cls._get_latest_commit_date(
"zhenxun-org", "zhenxun-bot-resources", "__version__"
)
(
release_data,
dev_version_text,
bot_commit_date,
res_commit_date,
) = await asyncio.gather(
release_task,
dev_version_task,
bot_commit_date_task,
res_commit_date_task,
return_exceptions=True,
)
if isinstance(release_data, dict):
bot_release_version = release_data.get("name", "获取失败")
bot_release_date = release_data.get("created_at", "").split("T")[0]
else:
bot_release_version = "获取失败"
bot_release_date = "获取失败"
logger.warning(f"获取 Bot release 信息失败: {release_data}")
if isinstance(dev_version_text, str):
bot_dev_version = dev_version_text.split(":")[-1].strip()
else:
bot_dev_version = "获取失败"
bot_commit_date = "获取失败"
logger.warning(f"获取 Bot dev 版本信息失败: {dev_version_text}")
bot_update_hint = ""
try:
cur_base_v = bot_cur_version.split("-")[0].lstrip("v")
dev_base_v = bot_dev_version.split("-")[0].lstrip("v")
if Version(cur_base_v) < Version(dev_base_v):
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
elif (
Version(cur_base_v) == Version(dev_base_v)
and bot_cur_version != bot_dev_version
):
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
except (InvalidVersion, TypeError, IndexError):
if bot_cur_version != bot_dev_version and bot_dev_version != "获取失败":
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
bot_update_info = (
f"当前版本: {bot_cur_version}\n"
f"最新开发版: {bot_dev_version} (更新于: {bot_commit_date})\n"
f"最新正式版: {bot_release_version} (发布于: {bot_release_date})"
f"{bot_update_hint}"
)
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
res_cur_version = "未找到"
if res_version_file.exists():
if text := res_version_file.open(encoding="utf8").readline():
res_cur_version = text.split(":")[-1].strip()
res_latest_version = "获取失败"
try:
res_latest_version_text = await RepoFileManager.get_file_content(
ZhenxunRepoConfig.RESOURCE_GITHUB_URL, "__version__"
)
res_latest_version = res_latest_version_text.split(":")[-1].strip()
except Exception as e:
res_commit_date = "获取失败"
logger.warning(f"获取资源版本信息失败: {e}")
res_update_hint = ""
try:
if Version(res_cur_version) < Version(res_latest_version):
res_update_hint = "\n-> 发现新资源版本, 可用 `检查更新 resource` 更新"
except (InvalidVersion, TypeError):
pass
res_update_info = (
f"当前版本: {res_cur_version}\n"
f"最新版本: {res_latest_version} (更新于: {res_commit_date})"
f"{res_update_hint}"
)
return f"『绪山真寻 Bot』\n{bot_update_info}\n\n『真寻资源』\n{res_update_info}"
@classmethod
async def update_webui(
@@ -125,6 +223,7 @@ class UpdateManager:
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
user_id,
)
result_message = ""
if zip:
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
await PlatformUtils.send_superuser(
@@ -133,7 +232,7 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE
)
return (
result_message = (
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
"请重新启动真寻以完成更新!"
)
@@ -155,13 +254,54 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE
)
return (
result_message = (
f"版本更新完成!\n"
f"版本: {cur_version} -> {result.new_version}\n"
f"变更文件个数: {len(result.changed_files)}"
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
"请重新启动真寻以完成更新!"
)
resource_warning = ""
if version_type == "main":
try:
spec_content = await RepoFileManager.get_file_content(
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "resources.spec"
)
required_spec_str = None
for line in spec_content.splitlines():
if line.startswith("require_resources_version:"):
required_spec_str = line.split(":", 1)[1].strip().strip("\"'")
break
if required_spec_str:
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
local_res_version_str = "0.0.0"
if res_version_file.exists():
if text := res_version_file.open(encoding="utf8").readline():
local_res_version_str = text.split(":")[-1].strip()
spec = SpecifierSet(required_spec_str)
local_ver = Version(local_res_version_str)
if not spec.contains(local_ver):
warning_header = (
f"⚠️ **资源版本不兼容!**\n"
f"当前代码需要资源版本: `{required_spec_str}`\n"
f"您当前的资源版本是: `{local_res_version_str}`\n"
"**将自动为您更新资源文件...**"
)
await PlatformUtils.send_superuser(bot, warning_header, user_id)
resource_update_source = None if zip else source
resource_update_result = await cls.update_resources(
source=resource_update_source, force=force
)
resource_warning = (
f"\n\n{warning_header}\n{resource_update_result}"
)
except Exception as e:
logger.warning(f"检查资源版本兼容性时出错: {e}", LOG_COMMAND, e=e)
resource_warning = (
"\n\n⚠️ 检查资源版本兼容性时出错,建议手动运行 `检查更新 resource`"
)
return result_message + resource_warning
@classmethod
def __get_version(cls) -> str:
@@ -19,12 +19,12 @@ from zhenxun.configs.config import Config
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata(
name="消息统计",
@@ -147,12 +147,14 @@ async def _(
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
)
avatar_url = PlatformUtils.get_user_avatar_url(uid_str, platform)
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
rows_data.append(
[
TextCell(content=str(len(rows_data) + 1)),
ImageCell(src=avatar_url or "", shape="circle"),
ImageCell(
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
),
TextCell(content=user_name),
TextCell(content=str(num), bold=True),
]
+11 -2
View File
@@ -78,12 +78,18 @@ _matcher = on_alconna(
Option("-s|--superuser", action=store_true, help_text="超级用户帮助"),
Option("-d|--detail", action=store_true, help_text="详细帮助"),
),
aliases={"help", "帮助", "菜单"},
aliases={"help", "菜单"},
rule=to_me(),
priority=1,
block=True,
)
_matcher.shortcut(
r"帮助(?P<name>.*?)",
command="功能",
arguments=["{name}"],
prefix=True,
)
_matcher.shortcut(
r"详细帮助",
@@ -109,8 +115,11 @@ async def _(
)
if name.available:
help_style = Config.get_config("help", "HELP_STYLE")
variant = help_style if help_style != "default" else None
traditional_help_result = await get_plugin_help(
session.user.id, name.result, _is_superuser
session.user.id, name.result, _is_superuser, variant=variant
)
is_plugin_found = not (
+25 -19
View File
@@ -13,11 +13,11 @@ from zhenxun.models.statistics import Statistics
from zhenxun.services import (
LLMException,
LLMMessage,
avatar_service,
generate,
)
from zhenxun.services.log import logger
from zhenxun.ui.builders import (
InfoCardBuilder,
NotebookBuilder,
PluginMenuBuilder,
)
@@ -25,7 +25,6 @@ from zhenxun.ui.models import PluginMenuCategory
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump
from ._utils import classify_plugin
@@ -107,7 +106,8 @@ async def create_help_img(
platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
bot_avatar_url = PlatformUtils.get_user_avatar_url(bot_id, platform) or ""
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname,
@@ -164,13 +164,16 @@ def split_text(text: str):
return [s.replace(" ", "&nbsp;") for s in split_text]
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
async def get_plugin_help(
user_id: str, name: str, is_superuser: bool, variant: str | None = None
) -> str | bytes:
"""获取功能的帮助信息
参数:
user_id: 用户id
name: 插件名称或id
is_superuser: 是否为超级用户
variant: 使用的皮肤/变体名称
"""
type_list = await get_user_allow_help(user_id)
if name.isdigit():
@@ -192,29 +195,32 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
return "该功能没有超级用户帮助信息"
usage = extra_data.superuser_help
builder = InfoCardBuilder(title=_plugin.metadata.name)
builder.add_metadata_items(
[
("作者", extra_data.author or "未知"),
("版本", extra_data.version or "未知"),
("调用次数", call_count),
]
)
metadata_items = [
{"label": "作者", "value": extra_data.author or "未知"},
{"label": "版本", "value": extra_data.version or "未知"},
{"label": "调用次数", "value": call_count},
]
processed_description = format_usage_for_markdown(
_plugin.metadata.description.strip()
)
processed_usage = format_usage_for_markdown(usage.strip())
builder.add_section("简介", [processed_description])
builder.add_section("使用方法", [processed_usage])
sections = [
{"title": "简介", "content": [processed_description]},
{"title": "使用方法", "content": [processed_usage]},
]
style_name = Config.get_config("help", "HELP_STYLE", "default")
render_dict = model_dump(builder._data)
render_dict["style_name"] = style_name
page_data = {
"title": _plugin.metadata.name,
"metadata": metadata_items,
"sections": sections,
}
return await ui.render_template("pages/builtin/help", data=render_dict)
component = ui.template("pages/builtin/help", data=page_data)
if variant:
component.variant = variant
return await ui.render(component, use_cache=True, device_scale_factor=2)
return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..."
+31 -1
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}",
+3 -3
View File
@@ -11,6 +11,7 @@ from zhenxun.models.level_user import LevelUser
from zhenxun.models.sign_user import SignUser
from zhenxun.models.statistics import Statistics
from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.platform import PlatformUtils
RACE = [
@@ -139,9 +140,8 @@ async def get_user_info(
bytes: 图片数据
"""
platform = PlatformUtils.get_platform(session) or "qq"
avatar_url = (
PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) or ""
)
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
avatar_url = avatar_path.as_uri() if avatar_path else ""
user = await UserConsole.get_user(user_id, platform)
permission_level = await LevelUser.get_user_level(user_id, group_id)
@@ -4,7 +4,7 @@ from zhenxun.services import renderer_service
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.types import ModelModality
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
from zhenxun.ui.models.core.table import StatusBadgeCell, TextCell
from zhenxun.ui.models import StatusBadgeCell, TextCell
def _format_seconds(seconds: int) -> str:
@@ -39,20 +39,19 @@ class Presenters:
return await renderer_service.render(builder.build())
column_name = ["提供商", "模型名称", "API类型", "状态"]
data_list = []
rows_data = []
for model in models:
is_available = model.get("is_available", True)
status_cell = StatusBadgeCell(
text="可用" if is_available else "不可用",
status_type="ok" if is_available else "error",
)
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
data_list.append(
rows_data.append(
[
TextCell(content=model.get("provider_name", "N/A")),
TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"),
TextCell(content=model.get("api_type", "N/A")),
status_cell,
StatusBadgeCell(
text="可用" if is_available else "不可用",
status_type="ok" if is_available else "error",
),
]
)
@@ -60,7 +59,8 @@ class Presenters:
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
builder.set_headers(column_name)
builder.add_rows(data_list)
builder.set_column_alignments(["left", "left", "left", "center"])
builder.add_rows(rows_data)
return await renderer_service.render(builder.build(), use_cache=True)
@staticmethod
@@ -11,6 +11,7 @@ from zhenxun.models.mahiro_bank import MahiroBank
from zhenxun.models.mahiro_bank_log import MahiroBankLog
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.enum import BankHandleType, GoldHandle
from zhenxun.utils.platform import PlatformUtils
@@ -210,9 +211,8 @@ class BankManager:
for deposit in user_today_deposit
]
platform = PlatformUtils.get_platform(session)
avatar_url = PlatformUtils.get_user_avatar_url(
user_id, platform, session.self_id
)
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
avatar_url = avatar_path.as_uri() if avatar_path else ""
return {
"name": uname,
"rank": rank + 1,
@@ -1,6 +1,6 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Args, Subcommand, on_alconna
from nonebot_plugin_alconna import Alconna, Args, Match, Option, Subcommand, on_alconna
from nonebot_plugin_session import EventSession
from zhenxun.configs.utils import PluginExtraData
@@ -16,11 +16,16 @@ __plugin_meta__ = PluginMetadata(
description="插件商店",
usage="""
插件商店 : 查看当前的插件商店
添加插件 id or module : 添加插件
移除插件 id or module : 移除插件
搜索插件 name or author : 搜索插件
更新插件 id or module : 更新插件
添加插件 id或module或插件名称 ?[-s [git, ali]]: 添加插件
使用-s时指定源,git为github,ali为阿里云
移除插件 id或module: 移除插件
搜索插件 name或author: 搜索插件
更新插件 id或module: 更新插件
更新全部插件 : 更新全部插件
示例:
添加插件 pix
添加插件 真寻日报 -s git
""".strip(),
extra=PluginExtraData(
author="HibiKier",
@@ -32,7 +37,11 @@ __plugin_meta__ = PluginMetadata(
_matcher = on_alconna(
Alconna(
"插件商店",
Subcommand("add", Args["plugin_id", str]),
Subcommand(
"add",
Args["plugin_id", str],
Option("-s", Args["source", str]),
),
Subcommand("remove", Args["plugin_id", str]),
Subcommand("search", Args["plugin_name_or_author", str]),
Subcommand("update", Args["plugin_id", str]),
@@ -84,7 +93,6 @@ async def _(session: EventSession):
try:
result = await StoreManager.get_plugins_info()
logger.info("查看插件列表", "插件商店", session=session)
await MessageUtils.build_message([*result]).send()
except Exception as e:
logger.error(f"查看插件列表失败 e: {e}", "插件商店", session=session, e=e)
@@ -92,13 +100,20 @@ async def _(session: EventSession):
@_matcher.assign("add")
async def _(session: EventSession, plugin_id: str):
async def _(session: EventSession, plugin_id: str, source: Match[str]):
if is_number(plugin_id):
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
else:
await MessageUtils.build_message(
f"正在添加插件 Module/名称: {plugin_id}"
).send()
source_str = source.result if source.available else None
if source_str and source_str not in ["ali", "git"]:
await MessageUtils.build_message(
f"源类型错误: {source_str} 请使用 ali 或 git"
).finish()
try:
if is_number(plugin_id):
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
else:
await MessageUtils.build_message(f"正在添加插件 Module: {plugin_id}").send()
result = await StoreManager.add_plugin(plugin_id)
result = await StoreManager.add_plugin(plugin_id, source_str)
except Exception as e:
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
await MessageUtils.build_message(
@@ -1,3 +1,4 @@
import os
from pathlib import Path
import random
import shutil
@@ -5,18 +6,17 @@ import shutil
from aiocache import cached
import ujson as json
from zhenxun import ui
from zhenxun.builtin_plugins.plugin_store.models import StorePluginInfo
from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger
from zhenxun.services.plugin_init import PluginInitManager
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import RepoFileManager
from zhenxun.utils.repo_utils.models import RepoFileInfo, RepoType
from zhenxun.utils.utils import is_number
from zhenxun.utils.utils import is_number, win_on_rm_error
from .config import (
BASE_PATH,
@@ -27,6 +27,22 @@ from .config import (
from .exceptions import PluginStoreException
def row_style(column: str, text: str) -> RowStyle:
"""被动技能文本风格
参数:
column: 表头
text: 文本内容
返回:
RowStyle: RowStyle
"""
style = RowStyle()
if column == "-" and text == "已安装":
style.font_color = "#67C23A"
return style
class StoreManager:
@classmethod
@cached(60)
@@ -91,123 +107,61 @@ class StoreManager:
return await PluginInfo.filter(load_status=True).values_list(*args)
@classmethod
async def get_plugins_info(cls) -> list[bytes] | str:
async def get_plugins_info(cls) -> list[BuildImage] | str:
"""插件列表
返回:
bytes | str: 返回消息
BuildImage | str: 返回消息
"""
plugin_list, extra_plugin_list = await cls.get_data()
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "0.1") for p in db_plugin_list}
HIGHLIGHT_COLOR = "#E6A23C"
structured_native_list = []
structured_extra_list = []
index = 0
data_list = []
extra_data_list = []
for plugin_info in plugin_list:
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
structured_native_list.append(
{
"is_installed": plugin_info.module in suc_plugin,
"id": index,
"name": plugin_info.name,
"description": plugin_info.description,
"author": plugin_info.author,
"version_str": cls.version_check(plugin_info, suc_plugin),
"type_name": plugin_info.plugin_type_name,
"has_update": not is_new and plugin_info.module in suc_plugin,
}
data_list.append(
[
"已安装" if plugin_info.module in suc_plugin else "",
index,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
)
index += 1
for plugin_info in extra_plugin_list:
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
structured_extra_list.append(
{
"is_installed": plugin_info.module in suc_plugin,
"id": index,
"name": plugin_info.name,
"description": plugin_info.description,
"author": plugin_info.author,
"version_str": cls.version_check(plugin_info, suc_plugin),
"type_name": plugin_info.plugin_type_name,
"has_update": not is_new and plugin_info.module in suc_plugin,
}
extra_data_list.append(
[
"已安装" if plugin_info.module in suc_plugin else "",
index,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
)
index += 1
native_table_builder = TableBuilder(
title="原生插件列表", tip="通过添加/移除插件 ID 来管理插件"
).set_headers(column_name)
native_rows_data = []
for row_data in structured_native_list:
row_color = HIGHLIGHT_COLOR if row_data["has_update"] else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if row_data["is_installed"]
else TextCell(content="")
)
native_rows_data.append(
[
status_cell,
TextCell(content=str(row_data["id"]), color=row_color),
TextCell(content=row_data["name"], color=row_color),
TextCell(content=row_data["description"], color=row_color),
TextCell(content=row_data["author"], color=row_color),
TextCell(
content=row_data["version_str"],
color=row_color,
bold=bool(row_color),
),
TextCell(content=row_data["type_name"], color=row_color),
]
)
native_table_builder.add_rows(native_rows_data)
native_table_bytes = await ui.render(
native_table_builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
extra_table_builder = TableBuilder(
title="第三方插件列表", tip="通过添加/移除插件 ID 来管理插件"
).set_headers(column_name)
extra_rows_data = []
for row_data in structured_extra_list:
row_color = HIGHLIGHT_COLOR if row_data["has_update"] else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if row_data["is_installed"]
else TextCell(content="")
)
extra_rows_data.append(
[
status_cell,
TextCell(content=str(row_data["id"]), color=row_color),
TextCell(content=row_data["name"], color=row_color),
TextCell(content=row_data["description"], color=row_color),
TextCell(content=row_data["author"], color=row_color),
TextCell(
content=row_data["version_str"],
color=row_color,
bold=bool(row_color),
),
TextCell(content=row_data["type_name"], color=row_color),
]
)
extra_table_builder.add_rows(extra_rows_data)
extra_table_bytes = await ui.render(
extra_table_builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
return [native_table_bytes, extra_table_bytes]
return [
await ImageTemplate.table_page(
"原生插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
data_list,
text_style=row_style,
),
await ImageTemplate.table_page(
"第三方插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
extra_data_list,
text_style=row_style,
),
]
@classmethod
async def get_plugin_by_value(
@@ -231,6 +185,8 @@ class StoreManager:
StorePluginInfo: 插件信息
bool: 是否是外部插件
"""
plugin_list: list[StorePluginInfo]
extra_plugin_list: list[StorePluginInfo]
plugin_list, extra_plugin_list = await cls.get_data()
plugin_info = None
is_external = False
@@ -254,6 +210,12 @@ class StoreManager:
if is_remove:
if plugin_info.module not in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除")
if plugin_obj := await PluginInfo.get_plugin(
module=plugin_info.module, plugin_type=PluginType.PARENT
):
plugin_info.module_path = plugin_obj.module_path
elif plugin_obj := await PluginInfo.get_plugin(module=plugin_info.module):
plugin_info.module_path = plugin_obj.module_path
return plugin_info, is_external
if is_update:
@@ -267,7 +229,7 @@ class StoreManager:
return plugin_info, is_external
@classmethod
async def add_plugin(cls, index_or_module: str) -> str:
async def add_plugin(cls, index_or_module: str, source: str | None = None) -> str:
"""添加插件
参数:
@@ -285,20 +247,18 @@ class StoreManager:
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
source,
)
return f"插件 {plugin_info.name} 安装成功! 重启后生效"
@classmethod
async def install_plugin_with_repo(
cls,
github_url: str,
module_path: str,
is_dir: bool,
plugin_info: StorePluginInfo,
is_external: bool = False,
source: str | None = None,
):
"""安装插件
@@ -309,19 +269,39 @@ class StoreManager:
is_external: 是否是外部仓库
"""
repo_type = RepoType.GITHUB if is_external else None
replace_module_path = module_path.replace(".", "/")
if source == "ali":
repo_type = RepoType.ALIYUN
elif source == "git":
repo_type = RepoType.GITHUB
module_path = plugin_info.module_path
is_dir = plugin_info.is_dir
github_url = plugin_info.github_url
assert github_url
replace_module_path = module_path.replace(".", "/").lstrip("/")
plugin_name = module_path.split(".")[-1] or plugin_info.module
if is_dir:
files = await RepoFileManager.list_directory_files(
github_url, replace_module_path, repo_type=repo_type
)
else:
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
local_path = BASE_PATH / "plugins" if is_external else BASE_PATH
if not is_external:
target_dir = BASE_PATH
elif is_dir and module_path == ".":
target_dir = BASE_PATH / "plugins" / plugin_name
else:
target_dir = BASE_PATH / "plugins"
files = [file for file in files if not file.is_dir]
download_files = [(file.path, local_path / file.path) for file in files]
await RepoFileManager.download_files(
github_url, download_files, repo_type=repo_type
download_files = [(file.path, target_dir / file.path) for file in files]
result = await RepoFileManager.download_files(
github_url,
download_files,
repo_type=repo_type,
sparse_path=replace_module_path,
target_dir=target_dir,
)
if not result.success:
raise PluginStoreException(result.error_message)
requirement_paths = [
file
@@ -332,12 +312,13 @@ class StoreManager:
is_install_req = False
for requirement_path in requirement_paths:
requirement_file = local_path / requirement_path.path
requirement_file = target_dir / requirement_path.path
if requirement_file.exists():
is_install_req = True
await VirtualEnvPackageManager.install_requirement(requirement_file)
if not is_install_req:
# 从仓库根目录查找文件
rand = random.randint(1, 10000)
requirement_path = TEMP_PATH / f"plugin_store_{rand}_req.txt"
requirements_path = TEMP_PATH / f"plugin_store_{rand}_reqs.txt"
@@ -374,38 +355,36 @@ class StoreManager:
str: 返回消息
"""
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
path = BASE_PATH
if plugin_info.github_url:
path = BASE_PATH / "plugins"
for p in plugin_info.module_path.split("."):
path = path / p
module_path = plugin_info.module_path
module = module_path.split(".")[-1]
path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
if not plugin_info.is_dir:
path = Path(f"{path}.py")
path = path.parent / f"{module}.py"
if not path.exists():
return f"插件 {plugin_info.name} 不存在..."
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
if plugin_info.is_dir:
shutil.rmtree(path)
# 处理 Windows 下 .git 等目录内只读文件导致的 WinError 5
shutil.rmtree(path, onerror=win_on_rm_error)
else:
path.unlink()
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}")
await PluginInitManager.remove(module_path)
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod
async def search_plugin(cls, plugin_name_or_author: str) -> bytes | str:
async def search_plugin(cls, plugin_name_or_author: str) -> BuildImage | str:
"""搜索插件
参数:
plugin_name_or_author: 插件名称或作者
返回:
bytes | str: 返回消息
BuildImage | str: 返回消息
"""
plugin_list, extra_plugin_list = await cls.get_data()
all_plugin_list = plugin_list + extra_plugin_list
db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
filtered_data = [
(id, plugin_info)
for id, plugin_info in enumerate(all_plugin_list)
@@ -413,50 +392,28 @@ class StoreManager:
or plugin_name_or_author.lower() in plugin_info.author.lower()
]
if not filtered_data:
data_list = [
[
"已安装" if plugin_info.module in suc_plugin else "",
id,
plugin_info.name,
plugin_info.description,
plugin_info.author,
cls.version_check(plugin_info, suc_plugin),
plugin_info.plugin_type_name,
]
for id, plugin_info in filtered_data
]
if not data_list:
return "未找到相关插件..."
HIGHLIGHT_COLOR = "#E6A23C"
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
builder = TableBuilder(
title=f"插件搜索结果: '{plugin_name_or_author}'",
tip="通过添加/移除插件 ID 来管理插件",
return await ImageTemplate.table_page(
"商店插件列表",
"通过添加/移除插件 ID 来管理插件",
column_name,
data_list,
text_style=row_style,
)
builder.set_headers(column_name)
rows_to_add = []
for id, plugin_info in filtered_data:
is_new = cls.check_version_is_new(plugin_info, suc_plugin)
has_update = not is_new and plugin_info.module in suc_plugin
row_color = HIGHLIGHT_COLOR if has_update else None
status_cell = (
StatusBadgeCell(text="已安装", status_type="ok")
if plugin_info.module in suc_plugin
else TextCell(content="")
)
rows_to_add.append(
[
status_cell,
TextCell(content=str(id), color=row_color),
TextCell(content=plugin_info.name, color=row_color),
TextCell(content=plugin_info.description, color=row_color),
TextCell(content=plugin_info.author, color=row_color),
TextCell(
content=cls.version_check(plugin_info, suc_plugin),
color=row_color,
bold=has_update,
),
TextCell(content=plugin_info.plugin_type_name, color=row_color),
]
)
builder.add_rows(rows_to_add)
render_viewport = {"width": 1400, "height": 10}
return await ui.render(builder.build(), viewport=render_viewport)
@classmethod
async def update_plugin(cls, index_or_module: str) -> str:
@@ -478,9 +435,7 @@ class StoreManager:
if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
)
return f"插件 {plugin_info.name} 更新成功! 重启后生效"
@@ -528,9 +483,7 @@ class StoreManager:
plugin_info.github_url = DEFAULT_GITHUB_URL
is_external = False
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
)
update_success_list.append(plugin_info.name)
@@ -582,11 +535,11 @@ class StoreManager:
raise PluginStoreException("插件ID不存在...")
return all_plugin_list[idx].module
elif isinstance(plugin_id, str):
result = (
None
if plugin_id not in [v.module for v in all_plugin_list]
else plugin_id
) or next(v for v in all_plugin_list if v.name == plugin_id).module
if not result:
raise PluginStoreException("插件 Module / 名称 不存在...")
return result
if plugin_id in [v.module for v in all_plugin_list]:
return plugin_id
for plugin_info in all_plugin_list:
if plugin_info.name.lower() == plugin_id.lower():
return plugin_info.module
raise PluginStoreException("插件 Module / 名称 不存在...")
+60 -85
View File
@@ -1,7 +1,6 @@
import asyncio
from datetime import datetime
import random
import time
from nonebot import on_message, on_request
from nonebot.adapters.onebot.v11 import (
@@ -12,7 +11,6 @@ from nonebot.adapters.onebot.v11 import (
from nonebot.adapters.onebot.v11 import Bot as v11Bot
from nonebot.adapters.onebot.v12 import Bot as v12Bot
from nonebot.plugin import PluginMetadata
from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig, Config
@@ -66,19 +64,6 @@ __plugin_meta__ = PluginMetadata(
)
class Timer:
data: dict[str, float] = {} # noqa: RUF012
@classmethod
def check(cls, uid: int | str):
return True if uid not in cls.data else time.time() - cls.data[uid] > 5 * 60
@classmethod
def clear(cls):
now = time.time()
cls.data = {k: v for k, v in cls.data.items() if v - now < 5 * 60}
# TODO: 其他平台请求
friend_req = on_request(priority=5, block=True)
@@ -86,68 +71,70 @@ group_req = on_request(priority=5, block=True)
_t = on_message(priority=999, block=False, rule=lambda: False)
cache = CacheRoot.cache_dict(
"REQUEST_CACHE", (base_config.get("TIP_MESSAGE_LIMIT") or 360) * 60, str
)
cache = CacheRoot.cache_dict("REQUEST_CACHE", 60, str)
@friend_req.handle()
async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSession):
if event.user_id and Timer.check(event.user_id):
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
user = await bot.get_stranger_info(user_id=event.user_id)
nickname = user["nickname"]
# sex = user["sex"]
# age = str(user["age"])
comment = event.comment
if base_config.get("AUTO_ADD_FRIEND"):
logger.debug(
"已开启好友请求自动同意,成功通过该请求",
"好友请求",
target=event.user_id,
)
await asyncio.sleep(random.randint(1, 10))
await bot.set_friend_add_request(flag=event.flag, approve=True)
await FriendUser.create(
user_id=str(user["user_id"]), user_name=user["nickname"]
)
else:
# 旧请求全部设置为过期
await FgRequest.filter(
request_type=RequestType.FRIEND,
user_id=str(event.user_id),
handle_type__isnull=True,
).update(handle_type=RequestHandleType.EXPIRE)
f = await FgRequest.create(
request_type=RequestType.FRIEND,
platform=session.platform,
bot_id=bot.self_id,
flag=event.flag,
user_id=event.user_id,
nickname=nickname,
comment=comment,
)
cache_key = str(event.user_id)
if not cache.get(cache_key):
cache.set(cache_key, "1")
results = await PlatformUtils.send_superuser(
bot,
f"*****一份好友申请*****\n"
f"ID: {f.id}\n"
f"昵称:{nickname}({event.user_id})\n"
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
f"日期:{datetime.now().replace(microsecond=0)}\n"
f"备注:{event.comment}",
)
if message_ids := [
str(r[1].msg_ids[0]["message_id"])
for r in results
if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
user = await bot.get_stranger_info(user_id=event.user_id)
nickname = user["nickname"]
# sex = user["sex"]
# age = str(user["age"])
comment = event.comment
if base_config.get("AUTO_ADD_FRIEND"):
logger.debug(
"已开启好友请求自动同意,成功通过该请求",
"好友请求",
target=event.user_id,
)
await asyncio.sleep(random.randint(1, 10))
await bot.set_friend_add_request(flag=event.flag, approve=True)
await FriendUser.create(
user_id=str(user["user_id"]), user_name=user["nickname"]
)
else:
logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id)
# 旧请求全部设置为过期
await FgRequest.filter(
request_type=RequestType.FRIEND,
user_id=str(event.user_id),
handle_type__isnull=True,
).update(handle_type=RequestHandleType.EXPIRE)
f = await FgRequest.create(
request_type=RequestType.FRIEND,
platform=session.platform,
bot_id=bot.self_id,
flag=event.flag,
user_id=event.user_id,
nickname=nickname,
comment=comment,
)
cache_key = str(event.user_id)
if not cache.get(cache_key):
cache.set(cache_key, "1")
results = await PlatformUtils.send_superuser(
bot,
f"*****一份好友申请*****\n"
f"ID: {f.id}\n"
f"昵称:{nickname}({event.user_id})\n"
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
f"日期:{datetime.now().replace(microsecond=0)}\n"
f"备注:{event.comment}",
)
if message_ids := [
str(r[1].msg_ids[0]["message_id"])
for r in results
if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
else:
tip_limit = base_config.get("TIP_MESSAGE_LIMIT") or 360
logger.debug(
f"好友请求{tip_limit}分钟内重复, 已忽略",
"好友请求",
target=cache_key,
)
@group_req.handle()
@@ -227,7 +214,7 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
"\n在群组中 群组管理员与群主 允许使用管理员帮助"
"(包括ban与功能开关等)\n请在群组中发送 '管理员帮助'",
)
elif cache.get(f"{event.group_id}"):
elif not cache.get(f"{event.group_id}"):
cache.set(f"{event.group_id}", "1")
logger.debug(
f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求",
@@ -284,15 +271,3 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
"群聊请求",
target=f"{event.user_id}:{event.group_id}",
)
@scheduler.scheduled_job(
"interval",
minutes=5,
)
async def _():
Timer.clear()
async def _():
Timer.clear()
@@ -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,
)
+1 -1
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).finish(reply_to=True) # type: ignore
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)
+28 -22
View File
@@ -21,9 +21,10 @@ from zhenxun.models.group_member_info import GroupInfoUser
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 import avatar_service
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 +93,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 +124,20 @@ 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
)
avatar_path = await avatar_service.get_avatar_path(platform, user[0])
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=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
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 +146,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 +500,7 @@ class ShopManage:
@classmethod
async def my_props(
cls, user_id: str, name: str, platform: str | None = None
) -> BuildImage | None:
) -> bytes | None:
"""获取道具背包
参数:
@@ -525,10 +532,10 @@ class ShopManage:
if not prop:
continue
icon = ""
icon = None
if prop.icon:
icon_path = ICON_PATH / prop.icon
icon = (icon_path, 33, 33) if icon_path.exists() else ""
icon = icon_path if icon_path.exists() else None
table_rows.append(
[
@@ -544,12 +551,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:
+24 -12
View File
@@ -6,14 +6,16 @@ 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
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.services.avatar_service import avatar_service
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 +35,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 +44,7 @@ class SignManage:
group_id: 群组id
返回:
BuildImage: 构造图片
bytes: 构造图片
"""
query = SignUser
if group_id:
@@ -78,17 +80,23 @@ class SignManage:
data_list = []
platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list):
bytes = await PlatformUtils.get_user_avatar(
user[0], platform, session.self_id
avatar_path = await avatar_service.get_avatar_path(
platform=user[3] or "qq", identifier=user[0]
)
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=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
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 +105,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(
+29 -27
View File
@@ -7,12 +7,11 @@ import aiofiles
import nonebot
from nonebot.drivers import Driver
from nonebot_plugin_uninfo import Uninfo
import pytz
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.services import avatar_service
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils
@@ -176,18 +175,17 @@ async def _generate_html_card(
impression = float(user.impression)
user_console = await user.user_console
uid_str = (
f"{user_console.uid:08}"
if user_console and user_console.uid is not None
else "XXXXXXXX"
)
uid_formatted = f"{uid_str[:4]} {uid_str[4:]}"
if user_console and user_console.uid is not None:
uid = f"{user_console.uid}".rjust(12, "0")
uid_formatted = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
else:
uid_formatted = "XXXX XXXX XXXX"
level, next_impression, previous_impression = get_level_and_next_impression(
impression
)
attitude = level2attitude.get(str(level), "未知")
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
interpolation_val = max(0, next_impression - impression)
interpolation = f"{interpolation_val:.2f}"
@@ -200,29 +198,38 @@ async def _generate_html_card(
hour = now.hour
if 6 < hour < 10:
bot_message = random.choice(MORNING_MESSAGE)
message = random.choice(MORNING_MESSAGE)
elif 0 <= hour < 6:
bot_message = random.choice(LG_MESSAGE)
message = random.choice(LG_MESSAGE)
else:
bot_message = f"{BotConfig.self_nickname}希望你开心!"
message = f"{BotConfig.self_nickname}希望你开心!"
bot_message = f"{BotConfig.self_nickname}说: {message}"
temperature = random.randint(1, 40)
weather_icon_name = f"{random.randint(0, 11)}.png"
tag_icon_name = f"{random.randint(0, 5)}.png"
font_size = 45
if len(nickname) > 6:
font_size = 27
avatar_path = await avatar_service.get_avatar_path(
PlatformUtils.get_platform(session), user.user_id
)
user_info = {
"nickname": nickname,
"uid_str": uid_formatted,
"avatar_url": PlatformUtils.get_user_avatar_url(
user.user_id, PlatformUtils.get_platform(session), session.self_id
)
or "",
"avatar_url": avatar_path.as_uri() if avatar_path else "",
"sign_count": user.sign_count,
"font_size": font_size,
}
favorability_info = {
"current": impression,
"level": level,
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
"heart2": [1 for _ in range(level)],
"heart1": [1 for _ in range(len(lik2level) - level - 1)],
"next_level_at": next_impression,
"previous_level_at": previous_impression,
}
@@ -230,7 +237,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 = (
@@ -241,15 +247,12 @@ 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_log = (
await SignLog.filter(user_id=user.user_id).order_by("-create_time").first()
)
last_date = "从未"
if last_log:
last_date = str(
last_log.create_time.astimezone(pytz.timezone("Asia/Shanghai")).date()
)
last_sign_date_str = f"上次签到:{last_date}"
reward_info = {
"impression_added": 0,
"gold_added": 0,
"gift_received": "",
"is_double": False,
}
else:
reward_info = {
@@ -278,7 +281,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)
@@ -1,8 +1,17 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Arparma,
Match,
Subcommand,
on_alconna,
)
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import renderer_service
from zhenxun.services.log import logger
@@ -14,7 +23,9 @@ __plugin_meta__ = PluginMetadata(
description="管理UI、主题和渲染服务的相关配置",
usage="""
指令:
重载UI主题
ui reload / 重载主题: 重新加载当前主题的配置和资源。
ui theme / 主题列表: 显示所有可用的主题,并高亮显示当前主题。
ui theme [主题名称] / 切换主题 [主题名称]: 将UI主题切换为指定主题。
""".strip(),
extra=PluginExtraData(
author="HibiKier",
@@ -37,22 +48,39 @@ __plugin_meta__ = PluginMetadata(
default_value=True,
type=bool,
),
RegisterConfig(
module="UI",
key="DEBUG_MODE",
value=False,
help="是否在日志中输出渲染组件的完整HTML源码,用于调试",
default_value=False,
type=bool,
),
],
).to_dict(),
)
_matcher = on_alconna(
Alconna("重载主题"),
ui_matcher = on_alconna(
Alconna(
"ui",
Subcommand("reload", help_text="重载当前主题"),
Subcommand("theme", Args["theme_name?", str], help_text="查看或切换主题"),
),
aliases={"主题管理"},
rule=to_me(),
permission=SUPERUSER,
priority=1,
block=True,
)
ui_matcher.shortcut("重载主题", command="ui reload")
ui_matcher.shortcut("主题列表", command="ui theme")
ui_matcher.shortcut("切换主题", command="ui theme", arguments=["{%0}"])
@_matcher.handle()
async def _(arparma: Arparma):
@ui_matcher.assign("reload")
async def handle_reload(arparma: Arparma):
theme_name = await renderer_service.reload_theme()
logger.info(
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
@@ -60,3 +88,55 @@ async def _(arparma: Arparma):
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'!").send(
reply_to=True
)
@ui_matcher.assign("theme")
async def handle_theme(
arparma: Arparma, theme_name_match: Match[str] = AlconnaMatch("theme_name")
):
if theme_name_match.available:
new_theme_name = theme_name_match.result
try:
await renderer_service.switch_theme(new_theme_name)
logger.info(
f"UI主题已切换为: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(
f"🎨 主题已成功切换为 '{new_theme_name}'!"
).send(reply_to=True)
except FileNotFoundError as e:
logger.warning(
f"尝试切换到不存在的主题: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(str(e)).send(reply_to=True)
except Exception as e:
logger.error(
f"切换主题时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message(f"切换主题失败: {e}").send(reply_to=True)
else:
try:
available_themes = renderer_service.list_available_themes()
current_theme = Config.get_config("UI", "THEME", "default")
theme_list_str = "\n".join(
f" - {theme}{' <- 当前' if theme == current_theme else ''}"
for theme in sorted(available_themes)
)
response = f"🎨 可用主题列表:\n{theme_list_str}"
await MessageUtils.build_message(response).send(reply_to=True)
except Exception as e:
logger.error(
f"获取主题列表时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message("获取主题列表失败。").send(reply_to=True)
@@ -16,7 +16,7 @@ from zhenxun.utils.platform import PlatformUtils
from ....base_model import Result
from ....config import QueryDateType
from ....utils import authentication, clear_help_image, get_system_status
from ....utils import authentication, get_system_status
from .data_source import ApiDataSource
from .model import (
ActiveGroup,
@@ -234,7 +234,6 @@ async def _(param: BotManageUpdateParam):
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
await bot_data.save(update_fields=["block_plugins", "block_tasks"])
clear_help_image()
return Result.ok()
except Exception as e:
logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=e)
@@ -7,7 +7,7 @@ from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from ....base_model import Result
from ....utils import authentication, clear_help_image
from ....utils import authentication
from .data_source import ApiDataSource
from .model import (
BatchUpdatePlugins,
@@ -82,7 +82,6 @@ async def _() -> Result[PluginCount]:
async def _(param: UpdatePlugin) -> Result:
try:
await ApiDataSource.update_plugin(param)
clear_help_image()
return Result.ok(info="已经帮你写好啦!")
except (ValueError, KeyError):
return Result.fail("插件数据不存在...")
@@ -110,7 +109,6 @@ async def _(param: PluginSwitch) -> Result:
db_plugin.block_type = None
db_plugin.status = True
await db_plugin.save()
clear_help_image()
return Result.ok(info="成功改变了开关状态!")
except Exception as e:
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
@@ -177,7 +175,6 @@ async def _(
updated_count=result_dict["updated_count"],
errors=result_dict["errors"],
)
clear_help_image()
return Result.ok(result_model, "插件配置更新完成")
except Exception as e:
logger.error(f"{router.prefix}/plugins/batch_update 调用错误", "WebUi", e=e)
@@ -197,7 +194,6 @@ async def _(payload: RenameMenuTypePayload) -> Result[str]:
old_name=payload.old_name, new_name=payload.new_name
)
if result.get("success"):
clear_help_image()
return Result.ok(
info=result.get(
"info",
+1 -17
View File
@@ -12,7 +12,7 @@ import psutil
import ujson as json
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.configs.path_config import DATA_PATH
from .base_model import SystemFolderSize, SystemStatus, User
@@ -68,22 +68,6 @@ def validate_path(path_str: str | None) -> tuple[Path | None, str | None]:
return None, f"路径验证失败: {e!s}"
GROUP_HELP_PATH = DATA_PATH / "group_help"
SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png"
SIMPLE_DETAIL_HELP_IMAGE = IMAGE_PATH / "SIMPLE_DETAIL_HELP.png"
def clear_help_image():
"""清理帮助图片"""
if SIMPLE_HELP_IMAGE.exists():
SIMPLE_HELP_IMAGE.unlink()
if SIMPLE_DETAIL_HELP_IMAGE.exists():
SIMPLE_DETAIL_HELP_IMAGE.unlink()
for file in GROUP_HELP_PATH.iterdir():
if file.is_file():
file.unlink()
def get_user(uname: str) -> User | None:
"""获取账号密码
+5 -3
View File
@@ -4,6 +4,7 @@ from typing_extensions import Self
from tortoise import fields
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import Model
from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType, DbLockType
@@ -57,14 +58,15 @@ class BanConsole(Model):
"""
if not user_id and not group_id:
raise UserAndGroupIsNone()
dao = DataAccess(cls)
if user_id:
return (
await cls.safe_get_or_none(user_id=user_id, group_id=group_id)
await dao.safe_get_or_none(user_id=user_id, group_id=group_id)
if group_id
else await cls.safe_get_or_none(user_id=user_id, group_id__isnull=True)
else await dao.safe_get_or_none(user_id=user_id, group_id__isnull=True)
)
else:
return await cls.safe_get_or_none(user_id="", group_id=group_id)
return await dao.safe_get_or_none(user_id="", group_id=group_id)
@classmethod
async def check_ban_level(
+4 -2
View File
@@ -7,6 +7,7 @@ from tortoise.backends.base.client import BaseDBAsyncClient
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.services.cache import CacheRoot
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import Model
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
@@ -254,13 +255,14 @@ class GroupConsole(Model):
返回:
Self: GroupConsole
"""
dao = DataAccess(cls)
if channel_id:
return await cls.safe_get_or_none(
return await dao.safe_get_or_none(
group_id=group_id,
channel_id=channel_id,
clean_duplicates=clean_duplicates,
)
return await cls.safe_get_or_none(
return await dao.safe_get_or_none(
group_id=group_id,
channel_id__isnull=True,
clean_duplicates=clean_duplicates,
+2 -2
View File
@@ -77,7 +77,7 @@ class PluginInfo(Model):
返回:
Self | None: 插件
"""
if filter_parent:
if not kwargs.get("plugin_type") and filter_parent:
return await cls.get_or_none(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
)
@@ -96,7 +96,7 @@ class PluginInfo(Model):
返回:
list[Self]: 插件列表
"""
if filter_parent:
if not kwargs.get("plugin_type") and filter_parent:
return await cls.filter(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
).all()
+2
View File
@@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender")
require("nonebot_plugin_uninfo")
require("nonebot_plugin_waiter")
from .avatar_service import avatar_service
from .db_context import Model, disconnect, with_db_timeout
from .llm import (
AI,
@@ -57,6 +58,7 @@ __all__ = [
"Model",
"PluginInit",
"PluginInitManager",
"avatar_service",
"chat",
"clear_model_cache",
"code",
+141
View File
@@ -0,0 +1,141 @@
"""
头像缓存服务
提供一个统一的、带缓存的头像获取服务,支持多平台和可配置的过期策略。
"""
import os
from pathlib import Path
import time
from nonebot_plugin_apscheduler import scheduler
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.log import logger
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.platform import PlatformUtils
Config.add_plugin_config(
"avatar_cache",
"ENABLED",
True,
help="是否启用头像缓存功能",
default_value=True,
type=bool,
)
Config.add_plugin_config(
"avatar_cache",
"TTL_DAYS",
7,
help="头像缓存的有效期(天)",
default_value=7,
type=int,
)
Config.add_plugin_config(
"avatar_cache",
"CLEANUP_INTERVAL_HOURS",
24,
help="后台清理过期缓存的间隔时间(小时)",
default_value=24,
type=int,
)
class AvatarService:
"""
一个集中式的头像缓存服务,提供L1(内存)和L2(文件)两级缓存。
"""
def __init__(self):
self.cache_path = (DATA_PATH / "cache" / "avatars").resolve()
self.cache_path.mkdir(parents=True, exist_ok=True)
self._memory_cache: dict[str, Path] = {}
def _get_cache_path(self, platform: str, identifier: str) -> Path:
"""
根据平台和ID生成存储的文件路径。
例如: data/cache/avatars/qq/123456789.png
"""
identifier = str(identifier)
return self.cache_path / platform / f"{identifier}.png"
async def get_avatar_path(
self, platform: str, identifier: str, force_refresh: bool = False
) -> Path | None:
"""
获取用户或群组的头像本地路径。
参数:
platform: 平台名称 (e.g., 'qq')
identifier: 用户ID或群组ID
force_refresh: 是否强制刷新缓存
返回:
Path | None: 头像的本地文件路径,如果获取失败则返回None。
"""
if not Config.get_config("avatar_cache", "ENABLED"):
return None
cache_key = f"{platform}-{identifier}"
if not force_refresh and cache_key in self._memory_cache:
if self._memory_cache[cache_key].exists():
return self._memory_cache[cache_key]
local_path = self._get_cache_path(platform, identifier)
ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400
if not force_refresh and local_path.exists():
try:
file_mtime = os.path.getmtime(local_path)
if time.time() - file_mtime < ttl_seconds:
self._memory_cache[cache_key] = local_path
return local_path
except FileNotFoundError:
pass
avatar_url = PlatformUtils.get_user_avatar_url(identifier, platform)
if not avatar_url:
return None
local_path.parent.mkdir(parents=True, exist_ok=True)
if await AsyncHttpx.download_file(avatar_url, local_path):
self._memory_cache[cache_key] = local_path
return local_path
else:
logger.warning(f"下载头像失败: {avatar_url}", "AvatarService")
return None
async def _cleanup_cache(self):
"""后台定时清理过期的缓存文件"""
if not Config.get_config("avatar_cache", "ENABLED"):
return
logger.info("开始执行头像缓存清理任务...", "AvatarService")
ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400
now = time.time()
deleted_count = 0
for root, _, files in os.walk(self.cache_path):
for name in files:
file_path = Path(root) / name
try:
if now - os.path.getmtime(file_path) > ttl_seconds:
file_path.unlink()
deleted_count += 1
except FileNotFoundError:
continue
logger.info(
f"头像缓存清理完成,共删除 {deleted_count} 个过期文件。", "AvatarService"
)
avatar_service = AvatarService()
@scheduler.scheduled_job(
"interval", hours=Config.get_config("avatar_cache", "CLEANUP_INTERVAL_HOURS", 24)
)
async def _run_avatar_cache_cleanup():
await avatar_service._cleanup_cache()
+12 -15
View File
@@ -37,7 +37,7 @@ class CacheDict(Generic[T]):
return 0
return data.expire_time
def __getitem__(self, key: str) -> T | None:
def __getitem__(self, key: str) -> T:
"""获取字典项
参数:
@@ -47,8 +47,10 @@ class CacheDict(Generic[T]):
T: 字典值
"""
if value := self._data.get(key):
return value.value if self.expire_time(key) else None
return None
if self.expire_time(key):
raise KeyError(f"键 {key} 已过期")
return value.value
raise KeyError(f"键 {key} 不存在")
def __setitem__(self, key: str, value: T) -> None:
"""设置字典项
@@ -78,16 +80,7 @@ class CacheDict(Generic[T]):
返回:
bool: 是否存在
"""
if key not in self._data:
return False
# 检查是否过期
data = self._data[key]
if data.expire_time > 0 and data.expire_time < time.time():
del self._data[key]
return False
return True
return False if key not in self._data else bool(self.expire_time(key))
def get(self, key: str, default: Any = None) -> T | None:
"""获取字典项,如果不存在返回默认值
@@ -99,8 +92,12 @@ class CacheDict(Generic[T]):
返回:
Any: 字典值或默认值
"""
value = self[key]
return default if value is None else value
if value := self._data.get(key):
if self.expire_time(key):
return default
if not value:
return default
return default if value.value is None else value.value
def set(self, key: str, value: Any, expire: int | None = None):
"""设置字典项
+5 -1
View File
@@ -7,6 +7,8 @@ from zhenxun.services.log import logger
T = TypeVar("T", bound=Model)
cache = CacheRoot.cache_dict("DB_TEST_BAN", 10, int)
class DataAccess(Generic[T]):
"""数据访问层,根据配置决定是否使用缓存
@@ -167,6 +169,7 @@ class DataAccess(Generic[T]):
return await with_db_timeout(
db_query_func(*args, **kwargs),
operation=f"{self.model_cls.__name__}.{db_query_func.__name__}",
source="DataAccess",
)
# 尝试从缓存获取
@@ -179,9 +182,10 @@ class DataAccess(Generic[T]):
if cache_key is not None:
data = await self.cache.get(cache_key)
logger.debug(
f"{self.model_cls.__name__} self.cache.get(cache_key)"
f"{self.model_cls.__name__} key: {cache_key}"
f" 从缓存获取到的数据 {type(data)}: {data}"
)
if data == self._NULL_RESULT:
# 空结果缓存命中
self._cache_stats[self.cache_type]["null_hits"] += 1
@@ -227,6 +227,7 @@ class Model(TortoiseModel):
return await with_db_timeout(
cls.get_or_none(*args, using_db=using_db, **kwargs),
operation=f"{cls.__name__}.get_or_none",
source="DataBaseModel",
)
except MultipleObjectsReturned:
# 如果出现多个记录的情况,进行特殊处理
@@ -239,6 +240,7 @@ class Model(TortoiseModel):
records = await with_db_timeout(
cls.filter(*args, **kwargs).all(),
operation=f"{cls.__name__}.filter.all",
source="DataBaseModel",
)
if not records:
@@ -255,6 +257,7 @@ class Model(TortoiseModel):
await with_db_timeout(
record.delete(),
operation=f"{cls.__name__}.delete_duplicate",
source="DataBaseModel",
)
logger.info(
f"{cls.__name__} 删除重复记录:"
@@ -269,11 +272,13 @@ class Model(TortoiseModel):
return await with_db_timeout(
cls.filter(*args, **kwargs).order_by("-id").first(),
operation=f"{cls.__name__}.filter.order_by.first",
source="DataBaseModel",
)
# 如果没有 id 字段,则返回第一个记录
return await with_db_timeout(
cls.filter(*args, **kwargs).first(),
operation=f"{cls.__name__}.filter.first",
source="DataBaseModel",
)
except asyncio.TimeoutError:
logger.error(
+5 -1
View File
@@ -11,11 +11,15 @@ from .config import (
async def with_db_timeout(
coro, timeout: float = DB_TIMEOUT_SECONDS, operation: str | None = None
coro,
timeout: float = DB_TIMEOUT_SECONDS,
operation: str | None = None,
source: str | None = None,
):
"""带超时控制的数据库操作"""
start_time = time.time()
try:
logger.debug(f"开始执行数据库操作: {operation} 来源: {source}")
result = await asyncio.wait_for(coro, timeout=timeout)
elapsed = time.time() - start_time
if elapsed > SLOW_QUERY_THRESHOLD and operation:
+1 -1
View File
@@ -87,7 +87,7 @@ class PluginInitManager:
@classmethod
async def remove(cls, module_path: str):
"""运行指定插件安装方法"""
"""运行指定插件移除方法"""
if model := cls.plugins.get(module_path):
if model.remove:
class_ = model.class_()
+13
View File
@@ -0,0 +1,13 @@
"""
渲染器服务的共享配置和常量
"""
RESERVED_TEMPLATE_KEYS: set[str] = {
"data",
"theme",
"theme_css",
"extra_css",
"required_scripts",
"required_styles",
"frameless",
}
+4
View File
@@ -33,6 +33,10 @@ class TemplateManifest(BaseModel):
entrypoint: str = Field(
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
)
styles: list[str] | str | None = Field(
None,
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
)
render_options: dict[str, Any] = Field(
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
)
+44 -5
View File
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable
from collections.abc import Awaitable, Iterable
from pathlib import Path
from typing import Any, Protocol
@@ -9,26 +9,50 @@ from pydantic import BaseModel
class Renderable(ABC):
"""
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
该协议确保了所有UI组件都能被 `RendererService` 以统一的方式处理。
任何想要被渲染服务处理的UI数据模型都应直接或间接实现此协议。
"""
component_css: str | None
@property
@abstractmethod
def template_name(self) -> str:
"""组件声明它需要哪个模板文件。"""
"""
返回用于渲染此组件的Jinja2模板的路径。
这是一个抽象属性,所有子类都必须覆盖它。
返回:
str: 指向模板文件的相对路径,例如 'components/core/table'。
"""
...
async def prepare(self) -> None:
"""
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
此方法会在组件的数据被传递给模板之前调用。
适合用于执行数据库查询、网络请求等耗时操作,以准备最终的渲染数据。
"""
pass
@abstractmethod
def get_children(self) -> Iterable["Renderable"]:
"""
[新增] 返回一个包含所有直接子组件的可迭代对象。
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
非容器组件应返回一个空列表。
"""
...
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
return []
def get_required_styles(self) -> list[str]:
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于assets目录)。"""
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
return []
@abstractmethod
@@ -36,13 +60,22 @@ class Renderable(ABC):
"""
返回一个将传递给模板的数据字典。
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
返回:
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
"""
...
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
"""
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
可以返回 str 或 awaitable[str]。
参数:
context: 当前的渲染上下文对象,可用于访问主题管理器等。
返回:
str | Awaitable[str]: 注入到页面的额外CSS字符串。
"""
return ""
@@ -50,6 +83,8 @@ class Renderable(ABC):
class ScreenshotEngine(Protocol):
"""
一个协议,定义了截图引擎的核心能力。
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
而无需修改上层渲染服务的代码。
"""
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
@@ -60,6 +95,9 @@ class ScreenshotEngine(Protocol):
html: 要渲染的HTML内容。
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
返回:
bytes: 渲染后的图片字节数据。
"""
...
@@ -67,6 +105,7 @@ class ScreenshotEngine(Protocol):
class RenderResult(BaseModel):
"""
渲染服务的统一返回类型。
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
"""
image_bytes: bytes | None = None
+32
View File
@@ -0,0 +1,32 @@
# File: zhenxun/services/renderer/registry.py
from pathlib import Path
from typing import ClassVar
from zhenxun.services.log import logger
class AssetRegistry:
"""一个独立的、用于存储由插件动态注册的资源的单例服务。"""
_markdown_styles: ClassVar[dict[str, Path]] = {}
def register_markdown_style(self, name: str, path: Path):
"""
为 Markdown 渲染器注册一个具名样式。
参数:
name (str): 样式的唯一名称。
path (Path): 指向该样式的CSS文件路径。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
def resolve_markdown_style(self, name: str) -> Path | None:
"""解析已注册的 Markdown 样式。"""
return self._markdown_styles.get(name)
asset_registry = AssetRegistry()
+366 -93
View File
@@ -1,13 +1,18 @@
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import hashlib
import inspect
from pathlib import Path
from typing import ClassVar, Literal
from typing import Any, ClassVar
import aiofiles
from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
)
from nonebot.utils import is_coroutine_callable
@@ -17,33 +22,101 @@ from zhenxun.configs.config import Config
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
from zhenxun.services.log import logger
from zhenxun.utils.exception import RenderingError
from zhenxun.utils.pydantic_compat import _dump_pydantic_obj
from .config import RESERVED_TEMPLATE_KEYS
from .engine import get_screenshot_engine
from .protocols import Renderable, RenderResult, ScreenshotEngine
from .theme import ThemeManager
from .registry import asset_registry
from .theme import RelativePathEnvironment, ThemeManager
@dataclass
class RenderContext:
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
renderer: "RendererService"
theme_manager: ThemeManager
screenshot_engine: ScreenshotEngine
component: Renderable
use_cache: bool
render_options: dict[str, Any]
resolved_template_paths: dict[str, str] = field(default_factory=dict)
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
collected_asset_styles: set[str] = field(default_factory=set)
collected_scripts: set[str] = field(default_factory=set)
collected_inline_css: list[str] = field(default_factory=list)
processed_components: set[int] = field(default_factory=set)
class RendererService:
"""
图片渲染服务的统一门面。
负责编排和调用底层渲染服务,提供统一的渲染接口。
支持多种渲染方式:组件渲染、模板渲染等。
作为UI渲染的中心枢纽,负责编排和调用底层服务,提供统一的渲染接口。
主要职责包括:
- 管理和加载UI主题 (通过 ThemeManager)。
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML。
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片。
- 处理插件注册的模板、过滤器和全局函数。
- (可选) 管理渲染结果的缓存。
"""
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
def __init__(self):
self._jinja_env: Environment | None = None
self._theme_manager: ThemeManager | None = None
self._screenshot_engine: ScreenshotEngine | None = None
self._initialized = False
self._init_lock = asyncio.Lock()
self._custom_filters: dict[str, Callable] = {}
self._custom_globals: dict[str, Callable] = {}
self._markdown_styles: dict[str, Path] = {}
self.filter("dump_json")(self._pydantic_tojson_filter)
def _create_jinja_env(self) -> Environment:
"""
创建并配置 Jinja2 渲染环境。
构建一个完整的 Jinja2 环境,包含:
- PrefixLoader:用于插件模板的命名空间加载
- FileSystemLoader:用于主题模板的文件系统加载
- RelativePathEnvironment:支持模板间相对路径引用的自定义环境
返回:
Environment: 完全配置好的 Jinja2 环境实例,准备接收自定义过滤器和全局函数。
"""
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in self._plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
final_loader = ChoiceLoader([prefix_loader, theme_loader])
env = RelativePathEnvironment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
return env
def register_template_namespace(self, namespace: str, path: Path):
"""[新增] 插件注册模板路径的入口点"""
"""
为插件注册一个Jinja2模板命名空间。
这允许插件在自己的目录中维护模板,并通过
`{% include '@namespace/template.html' %}` 的方式引用它们,
避免了与核心或其他插件的模板命名冲突。
参数:
namespace: 插件的唯一命名空间,例如插件名。
path: 包含该插件模板的目录路径。
"""
if namespace in self._plugin_template_paths:
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
if not path.is_dir():
@@ -52,18 +125,25 @@ class RendererService:
def register_markdown_style(self, name: str, path: Path):
"""
为 Markdown 渲染器注册一个具名样式。
为 Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)。
参数:
name (str): 样式的唯一名称,例如 'cyberpunk'。
path (Path): 指向该样式的CSS文件路径。
"""
if name in self._markdown_styles:
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
if not path.is_file():
raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。")
self._markdown_styles[name] = path
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
asset_registry.register_markdown_style(name, path)
def filter(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 过滤器。
参数:
name: 过滤器在模板中的调用名称。
返回:
Callable: 用于装饰过滤器函数的装饰器。
"""
def decorator(func: Callable) -> Callable:
@@ -78,6 +158,12 @@ class RendererService:
def global_function(self, name: str) -> Callable:
"""
装饰器:注册一个自定义 Jinja2 全局函数。
参数:
name: 函数在模板中的调用名称。
返回:
Callable: 用于装饰全局函数的装饰器。
"""
def decorator(func: Callable) -> Callable:
@@ -90,46 +176,143 @@ class RendererService:
return decorator
async def initialize(self):
"""[新增] 延迟初始化方法,在 on_startup 钩子中调用"""
"""
[新增] 延迟初始化方法,在 on_startup 钩子中调用。
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
使用锁来防止并发初始化。
"""
if self._initialized:
return
async with self._init_lock:
if self._initialized:
return
self._jinja_env = self._create_jinja_env()
self._jinja_env.filters.update(self._custom_filters)
self._jinja_env.globals.update(self._custom_globals)
self._screenshot_engine = get_screenshot_engine()
self._theme_manager = ThemeManager(
self._plugin_template_paths,
self._custom_filters,
self._custom_globals,
self._markdown_styles,
)
self._theme_manager = ThemeManager(self._jinja_env)
current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name)
self._initialized = True
async def _collect_dependencies_recursive(
self, component: Renderable, context: "RenderContext"
):
"""
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
这是实现组件化样式和脚本管理的基础,确保即使是深层嵌套的组件
所需的资源也能被正确加载到最终的HTML页面中。
"""
component_id = id(component)
if component_id in context.processed_components:
return
context.processed_components.add(component_id)
component_path_base = str(component.template_name)
manifest = await context.theme_manager.get_template_manifest(
component_path_base
)
style_paths_to_load = []
if manifest and manifest.styles:
styles = (
[manifest.styles]
if isinstance(manifest.styles, str)
else manifest.styles
)
for style_path in styles:
full_style_path = str(Path(component_path_base) / style_path).replace(
"\\", "/"
)
style_paths_to_load.append(full_style_path)
else:
resolved_template_name = (
await context.theme_manager._resolve_component_template(
component, context
)
)
conventional_style_path = str(
Path(resolved_template_name).with_name("style.css")
).replace("\\", "/")
style_paths_to_load.append(conventional_style_path)
for css_template_path in style_paths_to_load:
try:
css_template = context.theme_manager.jinja_env.get_template(
css_template_path
)
theme_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {})
}
css_content = await css_template.render_async(**theme_context)
context.collected_inline_css.append(css_content)
except TemplateNotFound:
pass
context.collected_scripts.update(component.get_required_scripts())
context.collected_asset_styles.update(component.get_required_styles())
if hasattr(component, "get_extra_css"):
res = component.get_extra_css(context)
css_str = await res if inspect.isawaitable(res) else str(res)
if css_str:
context.collected_inline_css.append(css_str)
for child in component.get_children():
if child:
await self._collect_dependencies_recursive(child, context)
async def _render_component(
self, component: Renderable, use_cache: bool = False, **render_options
self,
context: "RenderContext",
) -> RenderResult:
"""
核心的私有渲染方法,执行完整的渲染流程。
执行步骤:
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
并尝试从文件系统中读取缓存图片。
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
异步数据加载。
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
收集所有需要的CSS文件、JS文件和内联CSS。
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
此步骤会处理独立模板和主题内模板两种情况。
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
"""
return await self._apply_caching_layer(self._render_component_core, context)
async def _apply_caching_layer(
self,
core_render_func: Callable[..., Awaitable[RenderResult]],
context: "RenderContext",
) -> RenderResult:
"""
一个高阶函数,为核心渲染逻辑提供缓存层。
它负责处理缓存的读取和写入,而将实际的渲染工作委托给传入的函数。
"""
cache_path = None
if Config.get_config("UI", "CACHE") and use_cache:
component = context.component
if Config.get_config("UI", "CACHE") and context.use_cache:
try:
template_name = component.template_name
data_dict = component.get_render_data()
resolved_data_dict = {}
for key, value in data_dict.items():
if is_coroutine_callable(value): # type: ignore
resolved_data_dict[key] = await value
else:
resolved_data_dict[key] = value
data_str = json.dumps(resolved_data_dict, sort_keys=True)
cache_key_str = f"{template_name}:{data_str}"
cache_filename = (
f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png"
@@ -148,43 +331,46 @@ class RendererService:
logger.warning(f"UI缓存读取失败: {e}", e=e)
cache_path = None
result = await core_render_func(context)
if (
Config.get_config("UI", "CACHE")
and context.use_cache
and cache_path
and result.image_bytes
):
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(result.image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return result
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
"""
纯粹的核心渲染逻辑,不包含任何缓存处理。
此方法负责从组件数据模型生成最终的图片字节和HTML。
"""
component = context.component
try:
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
if hasattr(component, "prepare"):
await component.prepare()
required_scripts = set(component.get_required_scripts())
required_styles = set(component.get_required_styles())
if hasattr(component, "required_scripts"):
required_scripts.update(getattr(component, "required_scripts"))
if hasattr(component, "required_styles"):
required_styles.update(getattr(component, "required_styles"))
data_dict = component.get_render_data()
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
manifest_options = {}
if manifest := await self._theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
assert context.theme_manager is not None, "ThemeManager 未初始化"
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
if (
getattr(component, "_is_standalone_template", False)
and hasattr(component, "template_path")
hasattr(component, "template_path")
and isinstance(
template_path := getattr(component, "template_path"), Path
template_path := getattr(component, "template_path"),
Path,
)
and template_path.is_absolute()
):
await component.prepare()
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
template_dir = template_path.parent
@@ -195,45 +381,69 @@ class RendererService:
autoescape=select_autoescape(["html", "xml"]),
)
temp_env.globals["theme"] = self._theme_manager.jinja_env.globals.get(
"theme", {}
temp_env.globals.update(context.theme_manager.jinja_env.globals)
temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir)
)
temp_env.filters["md"] = self._theme_manager._markdown_filter
temp_env.filters["md"] = context.theme_manager._markdown_filter
data_dict = component.get_render_data()
template = temp_env.get_template(template_path.name)
html_content = await template.render_async(data=data_dict)
template_context = {
"theme": context.theme_manager.jinja_env.globals.get("theme", {}),
"data": data_dict,
}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 "
f"'data.{key}' 访问。"
)
else:
template_context[key] = value
html_content = await template.render_async(**template_context)
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
final_render_options = component_render_options.copy()
final_render_options.update(render_options)
final_render_options.update(context.render_options)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=template_dir,
**final_render_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
else:
await component.prepare()
await self._collect_dependencies_recursive(component, context)
data_dict = component.get_render_data()
component_render_options = data_dict.get("render_options", {})
if not isinstance(component_render_options, dict):
component_render_options = {}
manifest_options = {}
if manifest := await context.theme_manager.get_template_manifest(
component.template_name
):
manifest_options = manifest.render_options or {}
final_render_options = component_render_options.copy()
final_render_options.update(manifest_options)
final_render_options.update(render_options)
final_render_options.update(context.render_options)
if not self._theme_manager.current_theme:
if not context.theme_manager.current_theme:
raise RenderingError("渲染失败:主题未被正确加载。")
html_content = await self._theme_manager._render_component_to_html(
component,
required_scripts=list(required_scripts),
required_styles=list(required_styles),
html_content = await context.theme_manager._render_component_to_html(
context,
**final_render_options,
)
@@ -241,20 +451,12 @@ class RendererService:
screenshot_options.pop("extra_css", None)
screenshot_options.pop("frameless", None)
image_bytes = await self._screenshot_engine.render(
image_bytes = await context.screenshot_engine.render(
html=html_content,
base_url_path=THEMES_PATH.parent,
**screenshot_options,
)
if Config.get_config("UI", "CACHE") and use_cache and cache_path:
try:
async with aiofiles.open(cache_path, "wb") as f:
await f.write(image_bytes)
logger.debug(f"UI缓存写入成功: {cache_path}")
except Exception as e:
logger.warning(f"UI缓存写入失败: {e}", e=e)
return RenderResult(image_bytes=image_bytes, html_content=html_content)
except Exception as e:
@@ -271,27 +473,37 @@ class RendererService:
self,
component: Renderable,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**render_options,
) -> bytes:
"""
统一的、多态的渲染入口,直接返回图片字节。
参数:
component: 一个 Renderable 实例 (如 RenderableComponent) 或一个
模板路径字符串。
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
use_cache: (可选) 是否启用渲染缓存,默认为 False。
**render_options: 传递给底层渲染引擎的额外参数。
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
返回:
bytes: 渲染后的图片数据。
bytes: 渲染后的PNG图片字节数据。
异常:
RenderingError: 当渲染流程中任何步骤失败时抛出。
"""
result = await self._render_component(
component,
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=use_cache,
**render_options,
render_options=render_options,
)
if debug_mode == "log" and result.html_content:
result = await self._render_component(context)
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
logger.info(
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
f"{result.html_content}\n"
@@ -301,17 +513,44 @@ class RendererService:
raise RenderingError("渲染成功但未能生成图片字节数据。")
return result.image_bytes
async def render_to_html(self, component: Renderable) -> str:
"""调试方法:只执行到HTML生成步骤。"""
async def render_to_html(
self, component: Renderable, frameless: bool = False
) -> str:
"""
调试方法:只执行到HTML生成步骤,不进行截图。
参数:
component: 一个 `Renderable` 实例。
frameless: 是否以无边框模式渲染(只渲染HTML片段)。
返回:
str: 最终渲染出的完整HTML字符串。
"""
if not self._initialized:
await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化"
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
return await self._theme_manager._render_component_to_html(component)
context = RenderContext(
renderer=self,
theme_manager=self._theme_manager,
screenshot_engine=self._screenshot_engine,
component=component,
use_cache=False,
render_options={"frameless": frameless},
)
await self._collect_dependencies_recursive(component, context)
return await self._theme_manager._render_component_to_html(
context, frameless=frameless
)
async def reload_theme(self) -> str:
"""
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
这在开发主题时非常有用,可以热重载主题更改。
返回:
str: 已成功加载的主题名称。
"""
if not self._initialized:
await self.initialize()
@@ -321,3 +560,37 @@ class RendererService:
await self._theme_manager.load_theme(current_theme_name)
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
return current_theme_name
def list_available_themes(self) -> list[str]:
"""获取所有可用主题的列表。"""
if not self._initialized or not self._theme_manager:
raise RuntimeError("ThemeManager尚未初始化。")
return self._theme_manager.list_available_themes()
async def switch_theme(self, theme_name: str) -> str:
"""
切换UI主题,加载新主题并持久化配置。
返回:
str: 已成功切换到的主题名称。
"""
if not self._initialized or not self._theme_manager:
await self.initialize()
assert self._theme_manager is not None
available_themes = self._theme_manager.list_available_themes()
if theme_name not in available_themes:
raise FileNotFoundError(
f"主题 '{theme_name}' 不存在。可用主题: {', '.join(available_themes)}"
)
await self._theme_manager.load_theme(theme_name)
Config.set_config("UI", "THEME", theme_name, auto_save=True)
logger.info(f"UI主题已切换为: {theme_name}")
return theme_name
@staticmethod
def _pydantic_tojson_filter(obj: Any) -> str:
"""一个能够递归处理Pydantic模型及其集合的 tojson 过滤器"""
dumped_obj = _dump_pydantic_obj(obj)
return json.dumps(dumped_obj, ensure_ascii=False)
+400 -120
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import Callable
import inspect
import os
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import aiofiles
from jinja2 import (
@@ -10,9 +12,10 @@ from jinja2 import (
FileSystemLoader,
PrefixLoader,
TemplateNotFound,
select_autoescape,
pass_context,
)
import markdown
from markupsafe import Markup
from pydantic import BaseModel
import ujson as json
@@ -20,9 +23,30 @@ from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from zhenxun.services.renderer.models import TemplateManifest
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.exception import RenderingError
from zhenxun.services.renderer.registry import asset_registry
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from .service import RenderContext
from .config import RESERVED_TEMPLATE_KEYS
class RelativePathEnvironment(Environment):
"""
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
"""
def join_path(self, template: str, parent: str) -> str:
"""
如果模板路径以 './' 或 '../' 开头,则视为相对于父模板的路径进行解析。
否则,使用默认的解析行为。
"""
if template.startswith("./") or template.startswith("../"):
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
return path.replace(os.path.sep, "/")
return super().join_path(template, parent)
class Theme(BaseModel):
name: str
@@ -32,42 +56,229 @@ 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,
plugin_template_paths: dict[str, Path],
custom_filters: dict[str, Callable],
custom_globals: dict[str, Callable],
markdown_styles: dict[str, Path],
):
prefix_loader = PrefixLoader(
{
namespace: FileSystemLoader(str(path.absolute()))
for namespace, path in plugin_template_paths.items()
}
)
theme_loader = FileSystemLoader(
[
str(THEMES_PATH / "current_theme_placeholder" / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
)
final_loader = ChoiceLoader([prefix_loader, theme_loader])
def __init__(self, env: Environment):
"""
主题管理器,负责UI主题的加载、解析和模板渲染。
self.jinja_env = Environment(
loader=final_loader,
enable_async=True,
autoescape=select_autoescape(["html", "xml"]),
)
主要职责:
- 加载和管理UI主题,包括 `palette.json` (调色板) 和 `theme.css.jinja`(主题样式)
- 配置和持有核心的 Jinja2 环境实例。
- 向 Jinja2 环境注入全局函数,如 `asset()` 和 `render()`,供模板使用。
- 实现`asset()`函数的资源解析逻辑,支持皮肤、组件、主题和默认主题之间的资源回退
- 封装将 `Renderable` 组件渲染为最终HTML的复杂逻辑。
"""
self.jinja_env = env
self.current_theme: Theme | None = None
self._custom_filters = custom_filters
self._custom_globals = custom_globals
self._markdown_styles = markdown_styles
self.jinja_env.globals["render"] = self._global_render_component
self.jinja_env.globals["asset"] = self._create_asset_loader()
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
self.jinja_env.filters["md"] = self._markdown_filter
def list_available_themes(self) -> list[str]:
"""扫描主题目录并返回所有可用的主题名称。"""
if not THEMES_PATH.is_dir():
return []
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
def _create_asset_loader(self) -> Callable[..., str]:
"""
创建一个闭包函数 (Jinja2中的 `asset()` 函数),使用
ResourceResolver 进行路径解析。
"""
resolver = ResourceResolver(self)
@pass_context
def asset_loader(ctx, asset_path: str) -> str:
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。"""
resolver = ResourceResolver(self)
def asset_loader(asset_path: str) -> str:
return resolver.resolve_asset_uri(asset_path, str(local_base_path))
return asset_loader
async def _global_render_component(self, component: Renderable | None) -> str:
"""
一个全局的Jinja2函数,用于在模板内部渲染子组件
它封装了查找模板、设置上下文和渲染的逻辑。
"""
if not component:
return ""
try:
class MockContext:
def __init__(self):
self.resolved_template_paths = {}
self.theme_manager = self
mock_context = MockContext()
template_path = await self._resolve_component_template(
component,
mock_context, # type: ignore
)
template = self.jinja_env.get_template(template_path)
template_context = {
"data": component,
"frameless": True,
}
render_data = component.get_render_data()
template_context.update(render_data)
return Markup(await template.render_async(**template_context))
except Exception as e:
logger.error(
f"在全局 render 函数中渲染组件 '{component.__class__.__name__}' 失败",
e=e,
)
return f"<!-- 组件渲染失败{component.__class__.__name__}: {e} -->"
@staticmethod
def _markdown_filter(text: str) -> str:
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
@@ -95,18 +306,22 @@ class ThemeManager:
theme_name = "default"
theme_dir = THEMES_PATH / "default"
default_palette_path = THEMES_PATH / "default" / "palette.json"
default_palette = (
json.loads(default_palette_path.read_text("utf-8"))
if default_palette_path.exists()
else {}
)
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
current_loaders = list(self.jinja_env.loader.loaders)
if len(current_loaders) > 1:
current_loaders[1] = FileSystemLoader(
[
str(theme_dir / "templates"),
str(THEMES_PATH / "default" / "templates"),
]
if len(current_loaders) > 1 and isinstance(
current_loaders[0], PrefixLoader
):
prefix_loader = current_loaders[0]
new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
self.jinja_env.loader = ChoiceLoader(current_loaders)
else:
logger.error("Jinja2 loader 不是 ChoiceLoader 或未设置,无法更新主题路径。")
self.jinja_env.loader.loaders = [prefix_loader, new_theme_loader]
palette_path = theme_dir / "palette.json"
palette = (
@@ -126,42 +341,74 @@ class ThemeManager:
"default_assets_dir": THEMES_PATH / "default" / "assets",
}
self.jinja_env.globals["theme"] = theme_context_dict
self.jinja_env.globals["default_theme_palette"] = default_palette
logger.info(f"主题管理器已加载主题: {theme_name}")
async def _resolve_component_template(self, component_path: str) -> str:
async def _resolve_component_template(
self, component: Renderable, context: "RenderContext"
) -> str:
"""
智能解析组件路径。
如果路径是目录,则查找 manifest.json 以获取入口点。
智能解析组件模板的路径,支持简单组件和带皮肤(variant)的复杂组件。
查找顺序如下:
1. **带皮肤的组件**: 如果组件定义了 `variant`,则在
`components/{component_name}/skins/{variant_name}/` 目录下查找入口文件。
2. **标准组件**: 在组件的根目录 `components/{component_name}/` 下查找入口文件。
3. **兼容模式**: (作为最终回退)直接查找名为`components/{component_name}.html`
的文件
入口文件名默认为 `main.html`,但可以被组件目录下的 `manifest.json` 文件中的
`entrypoint` 字段覆盖。
"""
if Path(component_path).suffix:
return component_path
component_path_base = str(component.template_name)
manifest_path_str = f"{component_path}/manifest.json"
variant = getattr(component, "variant", None)
cache_key = f"{component_path_base}::{variant or 'default'}"
if cached_path := context.resolved_template_paths.get(cache_key):
logger.trace(f"模板路径缓存命中: '{cache_key}' -> '{cached_path}'")
return cached_path
if not self.jinja_env.loader:
raise TemplateNotFound(
f"Jinja2 loader 未配置。无法查找 '{manifest_path_str}'"
if Path(component_path_base).suffix:
try:
self.jinja_env.get_template(component_path_base)
logger.debug(f"解析到直接模板路径: '{component_path_base}'")
return component_path_base
except TemplateNotFound as e:
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
raise e
entrypoint_filename = "main.html"
manifest = await self.get_template_manifest(component_path_base)
if manifest and manifest.entrypoint:
entrypoint_filename = manifest.entrypoint
potential_paths = []
if variant:
potential_paths.append(
f"{component_path_base}/skins/{variant}/{entrypoint_filename}"
)
try:
_, full_path, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str
)
if full_path and Path(full_path).exists():
async with aiofiles.open(full_path, encoding="utf-8") as f:
manifest_data = json.loads(await f.read())
entrypoint = manifest_data.get("entrypoint")
if not entrypoint:
raise RenderingError(
f"组件 '{component_path}' 的 manifest.json 中缺少 "
f"'entrypoint' 键。"
)
return f"{component_path}/{entrypoint}"
except TemplateNotFound:
logger.debug(
f"未找到 '{manifest_path_str}',将回退到默认的 'main.html' 入口点。"
)
return f"{component_path}/main.html"
raise TemplateNotFound(f"无法为组件 '{component_path}' 找到模板入口点。")
potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
if entrypoint_filename == "main.html":
potential_paths.append(f"{component_path_base}.html")
for path in potential_paths:
try:
self.jinja_env.get_template(path)
logger.debug(f"解析到模板路径: '{path}'")
context.resolved_template_paths[cache_key] = path
return path
except TemplateNotFound:
continue
err_msg = (
f"无法为组件 '{component_path_base}' 找到任何可用的模板。"
f"检查路径: {potential_paths}"
)
logger.error(err_msg)
raise TemplateNotFound(err_msg)
async def get_template_manifest(
self, component_path: str
@@ -186,82 +433,115 @@ class ThemeManager:
return None
return None
def _resolve_markdown_style_path(self, style_name: str) -> Path | None:
async def resolve_markdown_style_path(
self, style_name: str, context: "RenderContext"
) -> Path | None:
"""
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
[新逻辑] 使用传入的上下文进行缓存。
"""
if style_name in self._markdown_styles:
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
return self._markdown_styles[style_name]
if cached_path := context.resolved_style_paths.get(style_name):
logger.trace(f"Markdown样式路径缓存命中: '{style_name}'")
return cached_path
logger.warning(f"样式 '{style_name}' 在注册表中未找到。")
return None
resolved_path: Path | None = None
if registered_path := asset_registry.resolve_markdown_style(style_name):
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
resolved_path = registered_path
elif self.current_theme:
theme_style_path = (
self.current_theme.assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if theme_style_path.exists():
logger.debug(
f"在主题 '{self.current_theme.name}' 中找到"
f"Markdown 样式: '{style_name}'"
)
resolved_path = theme_style_path
default_style_path = (
self.current_theme.default_assets_dir
/ "css"
/ "styles"
/ "markdown"
/ f"{style_name}.css"
)
if not resolved_path and default_style_path.exists():
logger.debug(f"在 'default' 主题中找到 Markdown 样式: '{style_name}'")
resolved_path = default_style_path
if resolved_path:
context.resolved_style_paths[style_name] = resolved_path
else:
logger.warning(
f"Markdown 样式 '{style_name}' 在注册表和主题目录中均未找到。"
)
return resolved_path
async def _render_component_to_html(
self,
component: Renderable,
required_scripts: list[str] | None = None,
required_styles: list[str] | None = None,
context: "RenderContext",
**kwargs,
) -> str:
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
if not self.current_theme:
await self.load_theme()
component = context.component
assert self.current_theme is not None, "主题加载失败"
data_dict = component.get_render_data()
custom_style_css = ""
if hasattr(component, "get_extra_css"):
css_result = component.get_extra_css(self)
if inspect.isawaitable(css_result):
custom_style_css = await css_result
else:
custom_style_css = css_result
def asset_loader(asset_path: str) -> str:
"""[新增] 用于在Jinja2模板中解析静态资源的辅助函数。"""
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.relative_to(THEMES_PATH.parent).as_posix()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(THEMES_PATH.parent).as_posix()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
theme_context_dict = model_dump(self.current_theme)
theme_context_dict["asset"] = asset_loader
theme_css_template = self.jinja_env.get_template("theme.css.jinja")
theme_css_content = await theme_css_template.render_async(
theme=theme_context_dict
)
resolved_template_name = await self._resolve_component_template(
str(component.template_name)
component, context
)
logger.debug(
f"正在渲染组件 '{component.template_name}' "
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
"RendererService",
"渲染服务",
)
if self._custom_filters:
self.jinja_env.filters.update(self._custom_filters)
if self._custom_globals:
self.jinja_env.globals.update(self._custom_globals)
template = self.jinja_env.get_template(resolved_template_name)
unpacked_data = {}
for key, value in data_dict.items():
if key in RESERVED_TEMPLATE_KEYS:
logger.warning(
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
f"在模板 '{component.template_name}' 中请使用 'data.{key}' 访问。"
)
else:
unpacked_data[key] = value
template_context = {
"data": data_dict,
"data": component,
"theme": theme_context_dict,
"theme_css": "",
"custom_style_css": custom_style_css,
"required_scripts": required_scripts or [],
"required_styles": required_styles or [],
"frameless": kwargs.get("frameless", False),
}
template_context.update(unpacked_data)
template_context.update(kwargs)
return await template.render_async(**template_context)
html_fragment = await template.render_async(**template_context)
if not kwargs.get("frameless", False):
base_template = self.jinja_env.get_template("partials/_base.html")
page_context = {
"data": component,
"theme_css": theme_css_content,
"collected_inline_css": context.collected_inline_css,
"required_scripts": list(context.collected_scripts),
"collected_asset_styles": list(context.collected_asset_styles),
"body_content": html_fragment,
}
return await base_template.render_async(**page_context)
else:
return html_fragment
+101 -15
View File
@@ -1,8 +1,9 @@
from pathlib import Path
from typing import Any, Literal
from typing import Any
from zhenxun.services.renderer.protocols import Renderable
from . import builders
from .builders.core.layout import LayoutBuilder
from .models.core.base import RenderableComponent
from .models.core.markdown import MarkdownData
@@ -12,6 +13,14 @@ from .models.core.template import TemplateComponent
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
"""
创建一个基于独立模板文件的UI组件。
适用于不希望遵循标准主题结构,而是直接渲染单个HTML文件的场景。
参数:
path: 指向HTML模板文件的绝对或相对路径。
data: 传递给模板的上下文数据字典。
返回:
TemplateComponent: 一个可被 `render()` 函数处理的组件实例。
"""
if isinstance(path, str):
path = Path(path)
@@ -22,15 +31,35 @@ def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
"""
创建一个基于Markdown内容的UI组件。
参数:
content: 要渲染的Markdown字符串。
style: (可选) Markdown的样式名称(如 'github-light')或一个指向
自定义CSS文件的路径。
返回:
MarkdownData: 一个可被 `render()` 函数处理的组件实例。
"""
builder = builders.MarkdownBuilder().text(content)
component = builder.build()
if isinstance(style, Path):
return MarkdownData(markdown=content, css_path=str(style.absolute()))
return MarkdownData(markdown=content, style_name=style)
component.css_path = str(style.absolute())
else:
component.style_name = style
return component
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
"""
创建一个垂直布局组件。
便捷函数,用于将多个组件垂直堆叠。
参数:
children: 一个包含 `RenderableComponent` 实例的列表。
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
返回:
LayoutBuilder: 一个配置好的垂直布局构建器。
"""
builder = LayoutBuilder.column(**layout_options)
for child in children:
@@ -41,6 +70,14 @@ def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuil
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
"""
创建一个水平布局组件。
便捷函数,用于将多个组件水平排列。
参数:
children: 一个包含 `RenderableComponent` 实例的列表。
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
返回:
LayoutBuilder: 一个配置好的水平布局构建器。
"""
builder = LayoutBuilder.row(**layout_options)
for child in children:
@@ -53,15 +90,25 @@ async def render(
data: dict | None = None,
*,
use_cache: bool = False,
debug_mode: Literal["none", "log"] = "none",
**kwargs,
) -> bytes:
"""
统一的UI渲染入口。
这是第三方开发者最常用的函数,用于将任何可渲染对象转换为图片。
用法:
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
参数:
component_or_path: 一个 `Renderable` 实例,或一个指向模板文件的
`str` 或 `Path` 对象。
data: (可选) 当 `component_or_path` 是路径时,必须提供此数据字典。
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
**kwargs: 传递给底层截图引擎的额外参数,例如 `viewport`。
返回:
bytes: 渲染后的PNG图片字节数据。
"""
from zhenxun.services import renderer_service
@@ -73,9 +120,7 @@ async def render(
else:
component = component_or_path
return await renderer_service.render(
component, use_cache=use_cache, debug_mode=debug_mode, **kwargs
)
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
async def render_template(
@@ -94,6 +139,9 @@ async def render_template(
返回:
bytes: 渲染后的图片数据。
异常:
RenderingError: 渲染失败时抛出。
"""
return await render(path, data, use_cache=use_cache, **kwargs)
@@ -114,12 +162,16 @@ async def render_markdown(
返回:
bytes: 渲染后的图片数据。
异常:
RenderingError: 渲染失败时抛出。
"""
component: MarkdownData
builder = builders.MarkdownBuilder().text(md)
component = builder.build()
if isinstance(style, Path):
component = MarkdownData(markdown=md, css_path=str(style.absolute()))
component.css_path = str(style.absolute())
else:
component = MarkdownData(markdown=md, style_name=style)
component.style_name = style
return await render(component, use_cache=use_cache, **kwargs)
@@ -131,10 +183,44 @@ async def render_full_result(
component: Renderable, use_cache: bool = False, **kwargs
) -> RenderResult:
"""
渲染组件并返回包含图片和HTML的完整结果对象,用于调试和高级用途。
渲染组件并返回包含图片和HTML的完整结果对象。
主要用于调试或需要同时访问图片和其源HTML的场景。
参数:
component: 一个 `Renderable` 实例。
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
**kwargs: 传递给底层截图引擎的额外参数。
返回:
RenderResult: 一个包含 `image_bytes` 和 `html_content` 的Pydantic模型。
"""
from zhenxun.services import renderer_service
from zhenxun.services.renderer.service import RenderContext
return await renderer_service._render_component(
component, use_cache=use_cache, **kwargs
if not renderer_service._initialized:
await renderer_service.initialize()
assert renderer_service._theme_manager is not None, "ThemeManager 未初始化"
assert renderer_service._screenshot_engine is not None, "ScreenshotEngine 未初始化"
context = RenderContext(
renderer=renderer_service,
theme_manager=renderer_service._theme_manager,
screenshot_engine=renderer_service._screenshot_engine,
component=component,
use_cache=use_cache,
render_options=kwargs,
)
return await renderer_service._render_component(context)
__all__ = [
"builders",
"hstack",
"markdown",
"render",
"render_full_result",
"render_markdown",
"render_template",
"template",
"vstack",
]
+40 -10
View File
@@ -1,19 +1,49 @@
from . import widgets
from .core.layout import LayoutBuilder
from .core.markdown import MarkdownBuilder
from .core.notebook import NotebookBuilder
from .core.table import TableBuilder
from .presets.help_page import PluginHelpPageBuilder
from .presets.info_card import InfoCardBuilder
from .presets.plugin_menu import PluginMenuBuilder
from .charts import EChartsBuilder
from .components import (
AlertBuilder,
AvatarBuilder,
AvatarGroupBuilder,
BadgeBuilder,
DividerBuilder,
KpiCardBuilder,
ProgressBarBuilder,
TimelineBuilder,
UserInfoBlockBuilder,
)
from .core import (
CardBuilder,
DetailsBuilder,
LayoutBuilder,
ListBuilder,
MarkdownBuilder,
NotebookBuilder,
TableBuilder,
TextBuilder,
)
from .presets import (
PluginHelpPageBuilder,
PluginMenuBuilder,
)
__all__ = [
"InfoCardBuilder",
"AlertBuilder",
"AvatarBuilder",
"AvatarGroupBuilder",
"BadgeBuilder",
"CardBuilder",
"DetailsBuilder",
"DividerBuilder",
"EChartsBuilder",
"KpiCardBuilder",
"LayoutBuilder",
"ListBuilder",
"MarkdownBuilder",
"NotebookBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
"ProgressBarBuilder",
"TableBuilder",
"widgets",
"TextBuilder",
"TimelineBuilder",
"UserInfoBlockBuilder",
]
+65 -5
View File
@@ -7,14 +7,24 @@ T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
class BaseBuilder(Generic[T_DataModel]):
"""所有UI构建器的基类,提供通用的样式化和构建逻辑。"""
"""
所有UI构建器的通用基类。
它实现了Builder设计模式,提供了一个流畅的、链式调用的API来创建和配置UI组件的数据模型。
同时,它也提供了通用的样式化方法,如 `with_style`, `with_inline_style` 等。
参数:
T_DataModel: 与此构建器关联的 Pydantic 数据模型类型。
"""
def __init__(self, data_model: T_DataModel, template_name: str):
self._data: T_DataModel = data_model
self._style_name: str | None = None
self._template_name = template_name
self._inline_style: dict | None = None
self._extra_css: str | None = None
self._component_css: str | None = None
self._variant: str | None = None
self._extra_classes: list[str] = []
@property
def data(self) -> T_DataModel:
@@ -23,6 +33,12 @@ class BaseBuilder(Generic[T_DataModel]):
def with_style(self, style_name: str) -> Self:
"""
为组件应用一个特定的样式。
参数:
style_name: 在主题的CSS中定义的样式类名。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._style_name = style_name
return self
@@ -32,26 +48,70 @@ class BaseBuilder(Generic[T_DataModel]):
为组件的根元素应用动态的内联样式。
参数:
style: 一个CSS样式字典,例如 {"background-color":"#fff","font-size":"16px"}
style: 一个CSS样式字典,例如
`{"background-color":"#fff","font-size":"16px"}`。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._inline_style = style
return self
def with_extra_css(self, css: str) -> Self:
def with_variant(self, variant_name: str) -> Self:
"""
为组件应用一个特定的变体/皮肤。
参数:
variant_name: 在组件的 `skins/` 目录下定义的变体名称。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._variant = variant_name
return self
def with_component_css(self, css: str) -> Self:
"""
向页面注入一段自定义的CSS样式字符串。
参数:
css: 包含CSS规则的字符串。
返回:
Self: 当前构建器实例,以支持链式调用。
"""
self._extra_css = css
self._component_css = css
return self
def with_classes(self, *class_names: str) -> Self:
"""
为组件的根元素添加一个或多个CSS工具类。
这些类来自主题预定义的工具集。
示例: .with_classes("p-4", "text-center", "font-bold")
"""
self._extra_classes.extend(class_names)
return self
def build(self) -> T_DataModel:
"""
构建并返回配置好的数据模型。
这是构建过程的最后一步,它会将所有配置应用到数据模型上。
返回:
T_DataModel: 最终配置好的、可被渲染服务使用的数据模型实例。
"""
if self._style_name and hasattr(self._data, "style_name"):
setattr(self._data, "style_name", self._style_name)
if self._inline_style and hasattr(self._data, "inline_style"):
setattr(self._data, "inline_style", self._inline_style)
if self._component_css and hasattr(self._data, "component_css"):
setattr(self._data, "component_css", self._component_css)
if self._variant and hasattr(self._data, "variant"):
setattr(self._data, "variant", self._variant)
if self._extra_classes and hasattr(self._data, "extra_classes"):
setattr(self._data, "extra_classes", self._extra_classes)
return self._data
+150 -61
View File
@@ -2,87 +2,176 @@ from typing import Any, Generic, Literal, TypeVar
from typing_extensions import Self
from ..models.charts import (
BarChartData,
BaseChartData,
LineChartData,
LineChartSeries,
PieChartData,
PieChartDataItem,
EChartsAxis,
EChartsData,
EChartsGrid,
EChartsSeries,
EChartsTitle,
EChartsTooltip,
)
from .base import BaseBuilder
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
class BaseChartBuilder(BaseBuilder[T_ChartData], Generic[T_ChartData]):
"""所有图表构建器的基类"""
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
"""
一个统一的、泛型的 ECharts 图表构建器。
提供了设置 ECharts `option` 的核心方法,以及一些常用图表的便利方法。
"""
def set_title(self, title: str) -> Self:
self._data.title = title
return self
class BarChartBuilder(BaseChartBuilder[BarChartData]):
"""链式构建柱状图的辅助类 (支持横向和竖向)"""
def __init__(
self, title: str, direction: Literal["horizontal", "vertical"] = "horizontal"
):
data_model = BarChartData(
title=title, direction=direction, category_data=[], data=[]
def __init__(self, template_name: str, title: str):
model = EChartsData(
template_path=template_name,
title=EChartsTitle(text=title),
grid=None,
tooltip=None,
xAxis=None,
yAxis=None,
legend=None,
background_image=None,
)
super().__init__(data_model, template_name="components/charts/bar_chart")
super().__init__(model, template_name=template_name)
def add_data(self, category: str, value: float) -> Self:
"""添加一个数据点"""
self._data.category_data.append(category)
self._data.data.append(value)
return self
def add_data_items(
self, items: list[tuple[str, int | float]] | list[dict[str, Any]]
def set_title(
self, text: str, left: Literal["left", "center", "right"] = "center"
) -> Self:
for item in items:
if isinstance(item, tuple):
self.add_data(item[0], item[1])
elif isinstance(item, dict):
self.add_data(item.get("category", ""), item.get("value", 0))
self._data.title_model = EChartsTitle(text=text, left=left)
return self
def set_background_image(self, background_image: str) -> Self:
"""设置背景图片 (仅横向柱状图模板支持)"""
self._data.background_image = background_image
def set_grid(
self,
left: str | None = None,
right: str | None = None,
top: str | None = None,
bottom: str | None = None,
containLabel: bool = True,
) -> Self:
self._data.grid_model = EChartsGrid(
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
)
return self
class PieChartBuilder(BaseChartBuilder[PieChartData]):
"""链式构建饼图的辅助类"""
def __init__(self, title: str):
data_model = PieChartData(title=title, data=[])
super().__init__(data_model, template_name="components/charts/pie_chart")
def add_slice(self, name: str, value: float) -> Self:
"""添加一个饼图扇区"""
self._data.data.append(PieChartDataItem(name=name, value=value))
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
self._data.tooltip_model = EChartsTooltip(trigger=trigger)
return self
def set_x_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self._data.x_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
class LineChartBuilder(BaseChartBuilder[LineChartData]):
"""链式构建折线图的辅助类"""
def __init__(self, title: str):
data_model = LineChartData(title=title, category_data=[], series=[])
super().__init__(data_model, template_name="components/charts/line_chart")
def set_categories(self, categories: list[str]) -> Self:
"""设置X轴的分类标签"""
self._data.category_data = categories
def set_y_axis(
self,
type: Literal["category", "value", "time", "log"],
data: list[Any] | None = None,
show: bool = True,
) -> Self:
self._data.y_axis_model = EChartsAxis(type=type, data=data, show=show)
return self
def add_series(
self, name: str, data: list[int | float], smooth: bool = False
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
) -> Self:
"""添加一条折线"""
self._data.series.append(LineChartSeries(name=name, data=data, smooth=smooth))
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
self._data.series_models.append(series)
return self
def set_legend(
self,
data: list[str],
orient: Literal["horizontal", "vertical"] = "horizontal",
left: str = "auto",
) -> Self:
self._data.legend_model = {"data": data, "orient": orient, "left": left}
return self
def set_option(self, key: str, value: Any) -> Self:
"""
[高级] 设置 ECharts `option` 中的一个原始键值对。
这会覆盖由其他流畅API方法设置的同名配置。
"""
self._data.raw_options[key] = value
return self
def set_background_image(self, image_name: str) -> Self:
"""【兼容】为横向柱状图设置背景图片。"""
self._data.background_image = image_name
return self
def bar_chart(
title: str,
items: list[tuple[str, int | float]],
direction: Literal["horizontal", "vertical"] = "horizontal",
) -> EChartsBuilder:
"""便捷工厂函数:创建一个柱状图构建器。"""
builder = EChartsBuilder("components/charts/bar_chart", title)
categories = [item[0] for item in items]
values = [item[1] for item in items]
if direction == "horizontal":
builder.set_x_axis(type="value")
builder.set_y_axis(type="category", data=categories)
builder.add_series(
type="bar",
data=values,
)
else:
builder.set_x_axis(type="category", data=categories)
builder.set_y_axis(type="value")
builder.add_series(type="bar", data=values)
return builder
def pie_chart(title: str, items: list[tuple[str, int | float]]) -> EChartsBuilder:
"""便捷工厂函数:创建一个饼图构建器。"""
builder = EChartsBuilder("components/charts/pie_chart", title)
data = [{"name": name, "value": value} for name, value in items]
legend_data = [item[0] for item in items]
builder.set_legend(data=legend_data)
builder.add_series(
name=title,
type="pie",
data=data,
)
return builder
def line_chart(
title: str, categories: list[str], series: list[dict[str, Any]]
) -> EChartsBuilder:
"""便捷工厂函数:创建一个折线图构建器。"""
builder = EChartsBuilder("components/charts/line_chart", title)
builder.set_x_axis(type="category", data=categories)
builder.set_y_axis(type="value")
for s in series:
builder.add_series(
type="line",
name=s.get("name", ""),
data=s.get("data", []),
smooth=s.get("smooth", False),
)
return builder
def radar_chart(
title: str, indicators: list[tuple[str, int | float]], series: list[dict[str, Any]]
) -> EChartsBuilder:
"""便捷工厂函数:创建一个雷达图构建器。"""
builder = EChartsBuilder("components/charts/radar_chart", title)
legend_data = [s.get("name", "") for s in series]
radar_indicators = [{"name": name, "max": max_val} for name, max_val in indicators]
builder.set_legend(data=legend_data)
builder.set_option("radar", {"indicator": radar_indicators})
builder.add_series(type="radar", data=series)
return builder
@@ -0,0 +1,25 @@
"""
小组件构建器模块
包含各种UI小组件的构建器
"""
from .alert import AlertBuilder
from .avatar import AvatarBuilder, AvatarGroupBuilder
from .badge import BadgeBuilder
from .divider import DividerBuilder
from .kpi_card import KpiCardBuilder
from .progress_bar import ProgressBarBuilder
from .timeline import TimelineBuilder
from .user_info_block import UserInfoBlockBuilder
__all__ = [
"AlertBuilder",
"AvatarBuilder",
"AvatarGroupBuilder",
"BadgeBuilder",
"DividerBuilder",
"KpiCardBuilder",
"ProgressBarBuilder",
"TimelineBuilder",
"UserInfoBlockBuilder",
]
+23
View File
@@ -0,0 +1,23 @@
from typing import Literal
from typing_extensions import Self
from ...models.components.alert import Alert
from ..base import BaseBuilder
class AlertBuilder(BaseBuilder[Alert]):
"""链式构建提示/标注框组件的辅助类"""
def __init__(
self,
title: str,
content: str,
type: Literal["info", "success", "warning", "error"] = "info",
):
data_model = Alert(title=title, content=content, type=type)
super().__init__(data_model, template_name="components/widgets/alert")
def hide_icon(self) -> Self:
"""隐藏提示框的默认图标"""
self._data.show_icon = False
return self
+38
View File
@@ -0,0 +1,38 @@
from typing import Literal
from typing_extensions import Self
from ...models.components.avatar import Avatar, AvatarGroup
from ..base import BaseBuilder
class AvatarBuilder(BaseBuilder[Avatar]):
"""链式构建单个头像的辅助类"""
def __init__(self, src: str):
data_model = Avatar(src=src, shape="circle", size=50)
super().__init__(data_model, template_name="components/widgets/avatar")
def set_shape(self, shape: Literal["circle", "square"]) -> Self:
self._data.shape = shape
return self
def set_size(self, size: int) -> Self:
self._data.size = size
return self
class AvatarGroupBuilder(BaseBuilder[AvatarGroup]):
"""链式构建头像组的辅助类"""
def __init__(self):
data_model = AvatarGroup(avatars=[], spacing=-15, max_count=None)
super().__init__(data_model, template_name="components/widgets/avatar_group")
def add_avatar(self, avatar: Avatar | AvatarBuilder | str) -> Self:
if isinstance(avatar, str):
self._data.avatars.append(Avatar(src=avatar, shape="circle", size=50))
elif isinstance(avatar, AvatarBuilder):
self._data.avatars.append(avatar.build())
else:
self._data.avatars.append(avatar)
return self
+20
View File
@@ -0,0 +1,20 @@
from typing import Literal
from ...models.components.divider import Divider
from ..base import BaseBuilder
class DividerBuilder(BaseBuilder[Divider]):
"""链式构建分割线组件的辅助类"""
def __init__(
self,
margin: str = "2em 0",
color: str = "#f7889c",
style: Literal["solid", "dashed", "dotted"] = "solid",
thickness: str = "1px",
):
data_model = Divider(
margin=margin, color=color, style=style, thickness=thickness
)
super().__init__(data_model, template_name="components/widgets/divider")
@@ -0,0 +1,31 @@
from typing import Any, Literal
from typing_extensions import Self
from ...models.components.kpi_card import KpiCard
from ..base import BaseBuilder
class KpiCardBuilder(BaseBuilder[KpiCard]):
"""链式构建统计卡片(KPI Card)的辅助类"""
def __init__(self, label: str, value: Any):
data_model = KpiCard(label=label, value=value)
super().__init__(data_model, template_name="components/widgets/kpi_card")
def with_unit(self, unit: str) -> Self:
"""设置数值的单位"""
self._data.unit = unit
return self
def with_change(
self, change: str, type: Literal["positive", "negative", "neutral"] = "neutral"
) -> Self:
"""设置与上一周期的变化率"""
self._data.change = change
self._data.change_type = type
return self
def with_icon(self, svg_path: str) -> Self:
"""设置卡片图标 (提供SVG path data)"""
self._data.icon_svg = svg_path
return self
@@ -0,0 +1,28 @@
from typing_extensions import Self
from ...models.components.timeline import Timeline, TimelineItem
from ..base import BaseBuilder
class TimelineBuilder(BaseBuilder[Timeline]):
"""链式构建时间轴组件的辅助类"""
def __init__(self):
data_model = Timeline(items=[])
super().__init__(data_model, template_name="components/widgets/timeline")
def add_item(
self,
timestamp: str,
title: str,
content: str,
*,
icon: str | None = None,
color: str | None = None,
) -> Self:
"""向时间轴中添加一个事件点"""
item = TimelineItem(
timestamp=timestamp, title=title, content=content, icon=icon, color=color
)
self._data.items.append(item)
return self
+8
View File
@@ -3,14 +3,22 @@
包含基础的UI构建器类
"""
from .card import CardBuilder
from .details import DetailsBuilder
from .layout import LayoutBuilder
from .list import ListBuilder
from .markdown import MarkdownBuilder
from .notebook import NotebookBuilder
from .table import TableBuilder
from .text import TextBuilder
__all__ = [
"CardBuilder",
"DetailsBuilder",
"LayoutBuilder",
"ListBuilder",
"MarkdownBuilder",
"NotebookBuilder",
"TableBuilder",
"TextBuilder",
]
+26
View File
@@ -0,0 +1,26 @@
from typing_extensions import Self
from ...models.core.base import RenderableComponent
from ...models.core.card import CardData
from ..base import BaseBuilder
class CardBuilder(BaseBuilder[CardData]):
"""链式构建通用卡片容器的辅助类"""
def __init__(self, content: "RenderableComponent | BaseBuilder"):
content_model = content.build() if isinstance(content, BaseBuilder) else content
data_model = CardData(content=content_model)
super().__init__(data_model, template_name="components/core/card")
def set_header(self, header: "RenderableComponent | BaseBuilder") -> Self:
"""设置卡片的头部组件"""
header_model = header.build() if isinstance(header, BaseBuilder) else header
self._data.header = header_model
return self
def set_footer(self, footer: "RenderableComponent | BaseBuilder") -> Self:
"""设置卡片的尾部组件"""
footer_model = footer.build() if isinstance(footer, BaseBuilder) else footer
self._data.footer = footer_model
return self
+19
View File
@@ -0,0 +1,19 @@
from typing import Any
from typing_extensions import Self
from ...models.core.details import DetailsData, DetailsItem
from ..base import BaseBuilder
class DetailsBuilder(BaseBuilder[DetailsData]):
"""链式构建描述列表(键值对)的辅助类"""
def __init__(self, title: str | None = None):
data_model = DetailsData(title=title, items=[])
super().__init__(data_model, template_name="components/core/details")
def add_item(self, label: str, value: Any) -> Self:
"""向列表中添加一个键值对项目"""
value_str = str(value)
self._data.items.append(DetailsItem(label=label, value=value_str))
return self
+34 -22
View File
@@ -19,16 +19,32 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
self._options: dict[str, Any] = {}
@classmethod
def column(cls, **options: Any) -> Self:
def column(
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
) -> Self:
builder = cls()
builder._template_name = "layouts/column"
builder._template_name = "components/core/layouts/column"
builder._options["gap"] = gap
builder._options["align_items"] = align_items
builder._options.update(options)
return builder
@classmethod
def row(cls, **options: Any) -> Self:
def row(
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
) -> Self:
builder = cls()
builder._template_name = "layouts/row"
builder._template_name = "components/core/layouts/row"
builder._options["gap"] = gap
builder._options["align_items"] = align_items
builder._options.update(options)
return builder
@classmethod
def grid(cls, columns: int = 2, **options: Any) -> Self:
builder = cls()
builder._template_name = "components/core/layouts/grid"
builder._options["columns"] = columns
builder._options.update(options)
return builder
@@ -56,15 +72,15 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
metadata: dict[str, Any] | None = None,
) -> Self:
"""
向布局中添加一个组件,支持多种组件类型的添加。
向布局中添加一个组件项。
参数:
component: 一个 Builder 实例 (如 TableBuilder) 或一个 RenderableComponent
数据模型。
metadata: (可选) 与此项目关联的元数据,可用于模板。
component: 一个 `BaseBuilder` 实例 (如 `TableBuilder()`) 或一个已构建的
`RenderableComponent` 数据模型。
metadata: (可选) 与此项目关联的元数据,可在布局模板中访问。
返回:
Self: 返回当前布局构建器实例,支持链式调用。
Self: 当前构建器实例,以支持链式调用。
"""
component_data = (
component.data if isinstance(component, BaseBuilder) else component
@@ -76,28 +92,24 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
def add_option(self, key: str, value: Any) -> Self:
"""
为布局添加一个自定义选项,该选项会传递给模板。
为布局模板添加一个自定义选项。
例如,`add_option("padding", "30px")` 会在模板的 `data.options`
字典中添加 `{"padding": "30px"}`。
参数:
key: 选项的键名,用于在模板中引用。
value: 选项的值,可以是任意类型的数据。
key: 选项的键名。
value: 选项的值。
返回:
Self: 返回当前布局构建器实例,支持链式调用。
Self: 当前构建器实例,以支持链式调用。
"""
self._options[key] = value
return self
def build(self) -> LayoutData:
"""
[修改] 构建并返回 LayoutData 模型实例。
此方法现在是同步的,并且不执行渲染。
参数:
无
返回:
LayoutData: 配置好的布局数据模型。
构建并返回 LayoutData 模型实例。
"""
if not self._template_name:
raise ValueError(
@@ -106,4 +118,4 @@ class LayoutBuilder(BaseBuilder[LayoutData]):
self._data.options = self._options
self._data.layout_type = self._template_name.split("/")[-1]
return self._data
return super().build()
+31
View File
@@ -0,0 +1,31 @@
from typing_extensions import Self
from ...models.core.base import RenderableComponent
from ...models.core.list import ListData, ListItem
from ..base import BaseBuilder
class ListBuilder(BaseBuilder[ListData]):
"""链式构建通用列表的辅助类。"""
def __init__(self, ordered: bool = False):
data_model = ListData(ordered=ordered)
super().__init__(data_model, template_name="components/core/list")
def add_item(self, component: "BaseBuilder | RenderableComponent") -> Self:
"""
向列表中添加一个项目。
参数:
component: 一个 Builder 实例或一个 RenderableComponent 数据模型。
"""
component_data = (
component.build() if isinstance(component, BaseBuilder) else component
)
self._data.items.append(ListItem(component=component_data))
return self
def ordered(self, is_ordered: bool = True) -> Self:
"""设置列表是否为有序列表(带数字编号)。"""
self._data.ordered = is_ordered
return self
+14 -3
View File
@@ -4,6 +4,7 @@ from typing import Any
from ...models.core.markdown import (
CodeElement,
ComponentElement,
HeadingElement,
ImageElement,
ListElement,
@@ -12,6 +13,7 @@ from ...models.core.markdown import (
MarkdownElement,
QuoteElement,
RawHtmlElement,
RenderableComponent,
TableElement,
TextElement,
)
@@ -24,7 +26,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
"""链式构建Markdown图片的辅助类,支持上下文管理和组合。"""
def __init__(self):
data_model = MarkdownData(markdown="", width=800, css_path=None)
data_model = MarkdownData(elements=[], width=800, css_path=None)
super().__init__(data_model, template_name="components/core/markdown")
self._parts: list[MarkdownElement] = []
self._width: int = 800
@@ -78,6 +80,16 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
)
return self
def add_component(
self, component: "BaseBuilder | RenderableComponent"
) -> "MarkdownBuilder":
"""添加一个UI组件(如图表、卡片等)。"""
component_data = (
component.build() if isinstance(component, BaseBuilder) else component
)
self._append_element(ComponentElement(component=component_data))
return self
def add_builder(self, builder: "MarkdownBuilder") -> "MarkdownBuilder":
"""将另一个builder的内容组合进来。"""
if self._context_stack:
@@ -144,8 +156,7 @@ class MarkdownBuilder(BaseBuilder[MarkdownData]):
"""
构建并返回 MarkdownData 模型实例。
"""
final_markdown = "\n\n".join(part.to_markdown() for part in self._parts).strip()
self._data.markdown = final_markdown
self._data.elements = self._parts
self._data.width = self._width
self._data.css_path = self._css_path
return super().build()
+84 -6
View File
@@ -1,4 +1,13 @@
from ...models.core.table import TableCell, TableData
from pathlib import Path
from typing import Any, Literal
from ...models.core.table import (
BaseCell,
ImageCell,
TableCell,
TableData,
TextCell,
)
from ..base import BaseBuilder
__all__ = ["TableBuilder"]
@@ -11,17 +20,86 @@ class TableBuilder(BaseBuilder[TableData]):
data_model = TableData(title=title, tip=tip, headers=[], rows=[])
super().__init__(data_model, template_name="components/core/table")
def _normalize_cell(self, cell_data: Any) -> TableCell:
"""内部辅助方法,将各种原生数据类型转换为TableCell模型。"""
if isinstance(cell_data, BaseCell):
return cell_data # type: ignore
if isinstance(cell_data, str | int | float):
return TextCell(content=str(cell_data))
if isinstance(cell_data, Path):
return ImageCell(src=cell_data.resolve().as_uri())
if isinstance(cell_data, tuple) and len(cell_data) == 3:
if (
isinstance(cell_data[0], Path)
and isinstance(cell_data[1], int)
and isinstance(cell_data[2], int)
):
return ImageCell(
src=cell_data[0].resolve().as_uri(),
width=cell_data[1],
height=cell_data[2],
)
return TextCell(content="")
def set_headers(self, headers: list[str]) -> "TableBuilder":
"""设置表头"""
"""
设置表格的表头。
参数:
headers: 一个包含表头文本的字符串列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
self._data.headers = headers
return self
def set_column_alignments(
self, alignments: list[Literal["left", "center", "right"]]
) -> "TableBuilder":
"""
设置表格每列的文本对齐方式。
参数:
alignments: 一个包含 'left', 'center', 'right' 的对齐方式列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
self._data.column_alignments = alignments
return self
def set_column_widths(self, widths: list[str | int]) -> "TableBuilder":
"""设置每列的宽度"""
self._data.column_widths = widths
return self
def add_row(self, row: list[TableCell]) -> "TableBuilder":
"""添加单行数据"""
self._data.rows.append(row)
"""
向表格中添加一行数据。
参数:
row: 一个包含单元格数据的列表。单元格可以是字符串、数字或
`TextCell`, `ImageCell` 等模型实例。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
normalized_row = [self._normalize_cell(cell) for cell in row]
self._data.rows.append(normalized_row)
return self
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
"""批量添加多行数据"""
self._data.rows.extend(rows)
"""
向表格中批量添加多行数据, 并自动转换原生类型。
参数:
rows: 一个包含多行数据的列表。
返回:
TableBuilder: 当前构建器实例,以支持链式调用。
"""
for row in rows:
self.add_row(row)
return self
+62
View File
@@ -0,0 +1,62 @@
from typing import Literal
from typing_extensions import Self
from ...models.core.text import TextData, TextSpan
from ..base import BaseBuilder
class TextBuilder(BaseBuilder[TextData]):
"""链式构建轻量级富文本组件的辅助类"""
def __init__(self, text: str = ""):
data_model = TextData(spans=[], align="left")
super().__init__(data_model, template_name="components/core/text")
if text:
self.add_span(text)
def set_alignment(self, align: Literal["left", "right", "center"]) -> Self:
"""设置整个文本块的对齐方式"""
self._data.align = align
return self
def add_span(
self,
text: str,
*,
bold: bool = False,
italic: bool = False,
underline: bool = False,
strikethrough: bool = False,
code: bool = False,
color: str | None = None,
font_size: str | int | None = None,
font_family: str | None = None,
) -> Self:
"""
添加一个带有样式的文本片段。
参数:
text: 文本内容。
bold: 是否加粗。
italic: 是否斜体。
underline: 是否有下划线。
strikethrough: 是否有删除线。
code: 是否渲染为代码样式。
color: 文本颜色 (e.g., '#ff0000', 'red')。
font_size: 字体大小 (e.g., 16, '1.2em', '12px')。
font_family: 字体族。
"""
font_size_str = f"{font_size}px" if isinstance(font_size, int) else font_size
span = TextSpan(
text=text,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
code=code,
color=color,
font_size=font_size_str,
font_family=font_family,
)
self._data.spans.append(span)
return self
+1 -3
View File
@@ -3,12 +3,10 @@
包含预定义的UI组件构建器
"""
from .help_page import PluginHelpPageBuilder
from .info_card import InfoCardBuilder
from .plugin_help_page import PluginHelpPageBuilder
from .plugin_menu import PluginMenuBuilder
__all__ = [
"InfoCardBuilder",
"PluginHelpPageBuilder",
"PluginMenuBuilder",
]
-46
View File
@@ -1,46 +0,0 @@
from typing import Any
from ...models.presets.card import (
InfoCardData,
InfoCardMetadataItem,
InfoCardSection,
)
from ..base import BaseBuilder
__all__ = ["InfoCardBuilder"]
class InfoCardBuilder(BaseBuilder[InfoCardData]):
def __init__(self, title: str):
self._data = InfoCardData(title=title)
super().__init__(self._data, template_name="components/presets/info_card")
def add_metadata(self, label: str, value: str | int) -> "InfoCardBuilder":
self._data.metadata.append(InfoCardMetadataItem(label=label, value=value))
return self
def add_metadata_items(
self, items: list[tuple[str, Any]] | list[dict[str, Any]]
) -> "InfoCardBuilder":
for item in items:
if isinstance(item, tuple):
self.add_metadata(item[0], item[1])
elif isinstance(item, dict):
self.add_metadata(item.get("label", ""), item.get("value", ""))
return self
def add_section(self, title: str, content: str | list[str]) -> "InfoCardBuilder":
content_list = [content] if isinstance(content, str) else content
self._data.sections.append(InfoCardSection(title=title, content=content_list))
return self
def add_sections(
self, sections: list[tuple[str, str | list[str]]] | list[dict[str, Any]]
) -> "InfoCardBuilder":
for section in sections:
if isinstance(section, tuple):
self.add_section(section[0], section[1])
elif isinstance(section, dict):
self.add_section(section.get("title", ""), section.get("content", []))
return self
@@ -1,4 +1,4 @@
from ...models.presets.help_page import (
from ...models.presets.plugin_help_page import (
HelpCategory,
PluginHelpPageData,
)
@@ -13,7 +13,7 @@ class PluginHelpPageBuilder(BaseBuilder[PluginHelpPageData]):
bot_nickname=bot_nickname, page_title=page_title, categories=[]
)
super().__init__(self._data, template_name="pages/core/help_page")
super().__init__(self._data, template_name="pages/core/plugin_help_page")
def add_category(self, category: HelpCategory) -> "PluginHelpPageBuilder":
"""添加一个帮助分类"""
-14
View File
@@ -1,14 +0,0 @@
"""
小组件构建器模块
包含各种UI小组件的构建器
"""
from .badge import BadgeBuilder
from .progress_bar import ProgressBarBuilder
from .user_info_block import UserInfoBlockBuilder
__all__ = [
"BadgeBuilder",
"ProgressBarBuilder",
"UserInfoBlockBuilder",
]
+33 -30
View File
@@ -1,70 +1,71 @@
from .charts import (
BarChartData,
BaseChartData,
LineChartData,
LineChartSeries,
PieChartData,
PieChartDataItem,
EChartsData,
)
from .components.badge import Badge
from .components.divider import Divider, Rectangle
from .components.progress_bar import ProgressBar
from .components.user_info_block import UserInfoBlock
from .core.base import RenderableComponent
from .core.layout import LayoutData, LayoutItem
from .core.markdown import (
from .components import (
Badge,
Divider,
ProgressBar,
Rectangle,
UserInfoBlock,
)
from .core import (
BaseCell,
CodeElement,
ComponentCell,
HeadingElement,
ImageCell,
ImageElement,
LayoutData,
LayoutItem,
ListElement,
ListItemElement,
MarkdownData,
MarkdownElement,
NotebookData,
NotebookElement,
QuoteElement,
RawHtmlElement,
TableElement,
TextElement,
)
from .core.notebook import NotebookData, NotebookElement
from .core.table import (
BaseCell,
ImageCell,
RenderableComponent,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TableElement,
TextCell,
TextElement,
TextSpan,
)
from .presets import (
HelpCategory,
HelpItem,
PluginHelpPageData,
PluginMenuCategory,
PluginMenuData,
PluginMenuItem,
)
from .presets.card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .presets.help_page import HelpCategory, HelpItem, PluginHelpPageData
from .presets.plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"Badge",
"BarChartData",
"BaseCell",
"BaseChartData",
"CodeElement",
"ComponentCell",
"Divider",
"EChartsData",
"HeadingElement",
"HelpCategory",
"HelpItem",
"ImageCell",
"ImageElement",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"LayoutData",
"LayoutItem",
"LineChartData",
"LineChartSeries",
"ListElement",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
"NotebookData",
"NotebookElement",
"PieChartData",
"PieChartDataItem",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",
@@ -74,11 +75,13 @@ __all__ = [
"RawHtmlElement",
"Rectangle",
"RenderableComponent",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TableElement",
"TextCell",
"TextElement",
"TextSpan",
"UserInfoBlock",
]
+142 -43
View File
@@ -1,63 +1,162 @@
from typing import Literal
from abc import ABC, abstractmethod
from typing import Any, Literal
import uuid
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import model_dump
from .core.base import RenderableComponent
class BaseChartData(RenderableComponent):
class EChartsTitle(BaseModel):
text: str
"""图表主标题"""
left: Literal["left", "center", "right"] = "center"
"""标题水平对齐方式"""
class EChartsAxis(BaseModel):
type: Literal["category", "value", "time", "log"]
"""坐标轴类型"""
data: list[Any] | None = None
"""类目数据"""
show: bool = True
"""是否显示坐标轴"""
class EChartsSeries(BaseModel):
type: str
"""系列类型 (e.g., 'bar', 'line', 'pie')"""
data: list[Any]
"""系列数据"""
name: str | None = None
"""系列名称,用于 tooltip 的显示"""
label: dict[str, Any] | None = None
"""图形上的文本标签"""
itemStyle: dict[str, Any] | None = None
"""图形样式"""
barMaxWidth: int | None = None
"""柱条的最大宽度"""
smooth: bool | None = None
"""是否平滑显示折线"""
class EChartsTooltip(BaseModel):
trigger: Literal["item", "axis", "none"] = Field("item", description="触发类型")
"""触发类型"""
class EChartsGrid(BaseModel):
left: str | None = None
"""grid 组件离容器左侧的距离"""
right: str | None = None
"""grid 组件离容器右侧的距离"""
top: str | None = None
"""grid 组件离容器上侧的距离"""
bottom: str | None = None
"""grid 组件离容器下侧的距离"""
containLabel: bool = True
"""grid 区域是否包含坐标轴的刻度标签"""
class BaseChartData(RenderableComponent, ABC):
"""所有图表数据模型的基类"""
style_name: str | None = None
title: str
chart_id: str = Field(default_factory=lambda: f"chart-{uuid.uuid4().hex}")
"""组件的样式名称"""
chart_id: str = Field(
default_factory=lambda: f"chart-{uuid.uuid4().hex}",
description="图表的唯一ID,用于前端渲染",
)
"""图表的唯一ID,用于前端渲染"""
echarts_options: dict[str, Any] | None = None
"""原始ECharts选项,用于高级自定义"""
@abstractmethod
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
raise NotImplementedError
def get_render_data(self) -> dict[str, Any]:
"""为图表组件定制渲染数据,动态构建最终的 option 对象。"""
dumped_data = model_dump(self, exclude={"template_path"})
if hasattr(self, "build_option"):
dumped_data["option"] = self.build_option()
return dumped_data
def get_required_scripts(self) -> list[str]:
"""声明此组件需要 ECharts 库。"""
return ["js/echarts.min.js"]
class BarChartData(BaseChartData):
"""柱状图(支持横向和竖向)的数据模型"""
class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型"""
category_data: list[str]
data: list[int | float]
direction: Literal["horizontal", "vertical"] = "horizontal"
background_image: str | None = None
template_path: str = Field(..., exclude=True, description="图表组件的模板路径")
"""图表组件的模板路径"""
title_model: EChartsTitle | None = Field(
None, alias="title", description="标题组件"
)
"""标题组件"""
grid_model: EChartsGrid | None = Field(None, alias="grid", description="网格组件")
"""网格组件"""
tooltip_model: EChartsTooltip | None = Field(
None, alias="tooltip", description="提示框组件"
)
"""提示框组件"""
x_axis_model: EChartsAxis | None = Field(None, alias="xAxis", description="X轴配置")
"""X轴配置"""
y_axis_model: EChartsAxis | None = Field(None, alias="yAxis", description="Y轴配置")
"""Y轴配置"""
series_models: list[EChartsSeries] = Field(
default_factory=list, alias="series", description="系列列表"
)
"""系列列表"""
legend_model: dict[str, Any] | None = Field(
default_factory=dict, alias="legend", description="图例组件"
)
"""图例组件"""
raw_options: dict[str, Any] = Field(
default_factory=dict, description="用于 set_option 的原始覆盖选项"
)
"""用于 set_option 的原始覆盖选项"""
background_image: str | None = Field(None, description="用于横向柱状图的背景图片")
"""用于横向柱状图的背景图片"""
def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
option: dict[str, Any] = {}
key_map = {
"title": "title_model",
"grid": "grid_model",
"tooltip": "tooltip_model",
"xAxis": "x_axis_model",
"yAxis": "y_axis_model",
"series": "series_models",
"legend": "legend_model",
}
for echarts_key, model_attr in key_map.items():
model_instance = getattr(self, model_attr, None)
if model_instance:
if isinstance(model_instance, list):
option[echarts_key] = [
model_dump(m, exclude_none=True) for m in model_instance
]
elif isinstance(model_instance, BaseModel):
option[echarts_key] = model_dump(model_instance, exclude_none=True)
else:
option[echarts_key] = model_instance
option.update(self.raw_options)
return option
@property
def title(self) -> str:
"""为模板提供一个简单的字符串标题,保持向后兼容性。"""
return self.title_model.text if self.title_model else ""
@property
def template_name(self) -> str:
return "components/charts/bar_chart"
class PieChartDataItem(BaseModel):
name: str
value: int | float
class PieChartData(BaseChartData):
"""饼图的数据模型"""
data: list[PieChartDataItem]
@property
def template_name(self) -> str:
return "components/charts/pie_chart"
class LineChartSeries(BaseModel):
name: str
data: list[int | float]
smooth: bool = False
class LineChartData(BaseChartData):
"""折线图的数据模型"""
category_data: list[str]
series: list[LineChartSeries]
@property
def template_name(self) -> str:
return "components/charts/line_chart"
return self.template_path
+7
View File
@@ -3,15 +3,22 @@
包含各种UI组件的数据模型
"""
from .alert import Alert
from .badge import Badge
from .divider import Divider, Rectangle
from .kpi_card import KpiCard
from .progress_bar import ProgressBar
from .timeline import Timeline, TimelineItem
from .user_info_block import UserInfoBlock
__all__ = [
"Alert",
"Badge",
"Divider",
"KpiCard",
"ProgressBar",
"Rectangle",
"Timeline",
"TimelineItem",
"UserInfoBlock",
]
+27
View File
@@ -0,0 +1,27 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Alert"]
class Alert(RenderableComponent):
"""一个带样式的提示框组件,用于显示重要信息。"""
component_type: Literal["alert"] = "alert"
type: Literal["info", "success", "warning", "error"] = Field(
default="info", description="提示框的类型,决定了颜色和图标"
)
"""提示框的类型,决定了颜色和图标"""
title: str = Field(..., description="提示框的标题")
"""提示框的标题"""
content: str = Field(..., description="提示框的主要内容")
"""提示框的主要内容"""
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
"""是否显示与类型匹配的图标"""
@property
def template_name(self) -> str:
return "components/widgets/alert"
+41
View File
@@ -0,0 +1,41 @@
from typing import Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["Avatar", "AvatarGroup"]
class Avatar(RenderableComponent):
"""单个头像组件。"""
component_type: Literal["avatar"] = "avatar"
src: str = Field(..., description="头像的URL或Base64数据URI")
"""头像的URL或Base64数据URI"""
shape: Literal["circle", "square"] = Field("circle", description="头像形状")
"""头像形状"""
size: int = Field(50, description="头像尺寸(像素)")
"""头像尺寸(像素)"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
class AvatarGroup(RenderableComponent):
"""一组堆叠的头像组件。"""
component_type: Literal["avatar_group"] = "avatar_group"
avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
"""头像列表"""
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
"""头像间的间距(负数表示重叠)"""
max_count: int | None = Field(
None, description="最多显示的头像数量,超出部分会显示为'+N'"
)
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property
def template_name(self) -> str:
return "components/widgets/avatar"
+2
View File
@@ -12,10 +12,12 @@ class Badge(RenderableComponent):
component_type: Literal["badge"] = "badge"
text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info",
description="预设的颜色方案",
)
"""预设的颜色方案"""
@property
def template_name(self) -> str:
+8
View File
@@ -12,9 +12,13 @@ class Divider(RenderableComponent):
component_type: Literal["divider"] = "divider"
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property
def template_name(self) -> str:
@@ -26,9 +30,13 @@ class Rectangle(RenderableComponent):
component_type: Literal["rectangle"] = "rectangle"
height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property
def template_name(self) -> str:
+35
View File
@@ -0,0 +1,35 @@
from typing import Any, Literal
from pydantic import Field
from ..core.base import RenderableComponent
__all__ = ["KpiCard"]
class KpiCard(RenderableComponent):
"""一个用于展示关键性能指标(KPI)的统计卡片。"""
component_type: Literal["kpi_card"] = "kpi_card"
label: str = Field(..., description="指标的标签或名称")
"""指标的标签或名称"""
value: Any = Field(..., description="指标的主要数值")
"""指标的主要数值"""
unit: str | None = Field(default=None, description="数值的单位,可选")
"""数值的单位,可选"""
change: str | None = Field(
default=None, description="与上一周期的变化,例如 '+15%' 或 '-100'"
)
"""与上一周期的变化,例如 '+15%' 或 '-100'"""
change_type: Literal["positive", "negative", "neutral"] = Field(
default="neutral", description="变化的类型,用于决定颜色"
)
"""变化的类型,用于决定颜色"""
icon_svg: str | None = Field(
default=None, description="卡片中显示的可选图标 (SVG path data)"
)
"""卡片中显示的可选图标 (SVG path data)"""
@property
def template_name(self) -> str:
return "components/widgets/kpi_card"
@@ -12,12 +12,16 @@ class ProgressBar(RenderableComponent):
component_type: Literal["progress_bar"] = "progress_bar"
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary",
description="预设的颜色方案",
)
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property
def template_name(self) -> str:
+36
View File
@@ -0,0 +1,36 @@
from typing import Literal
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = ["Timeline", "TimelineItem"]
class TimelineItem(BaseModel):
"""时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
"""显示在时间点旁边的时间或标签"""
title: str = Field(..., description="事件的标题")
"""事件的标题"""
content: str = Field(..., description="事件的详细描述")
"""事件的详细描述"""
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
"""可选的自定义图标SVG路径"""
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
"""可选的自定义颜色,覆盖默认"""
class Timeline(RenderableComponent):
"""一个垂直时间轴组件,用于按顺序展示事件。"""
component_type: Literal["timeline"] = "timeline"
items: list[TimelineItem] = Field(
default_factory=list, description="时间轴项目列表"
)
"""时间轴项目列表"""
@property
def template_name(self) -> str:
return "components/widgets/timeline"
@@ -12,11 +12,15 @@ class UserInfoBlock(RenderableComponent):
component_type: Literal["user_info_block"] = "user_info_block"
avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)"
)
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property
def template_name(self) -> str:
+23 -1
View File
@@ -4,7 +4,10 @@
"""
from .base import RenderableComponent
from .card import CardData
from .details import DetailsData, DetailsItem
from .layout import LayoutData, LayoutItem
from .list import ListData, ListItem
from .markdown import (
CodeElement,
HeadingElement,
@@ -19,18 +22,34 @@ from .markdown import (
TextElement,
)
from .notebook import NotebookData, NotebookElement
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell
from .table import (
BaseCell,
ComponentCell,
ImageCell,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TextCell,
)
from .template import TemplateComponent
from .text import TextData, TextSpan
__all__ = [
"BaseCell",
"CardData",
"CodeElement",
"ComponentCell",
"DetailsData",
"DetailsItem",
"HeadingElement",
"ImageCell",
"ImageElement",
"LayoutData",
"LayoutItem",
"ListData",
"ListElement",
"ListItem",
"ListItemElement",
"MarkdownData",
"MarkdownElement",
@@ -39,11 +58,14 @@ __all__ = [
"QuoteElement",
"RawHtmlElement",
"RenderableComponent",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
"TableElement",
"TemplateComponent",
"TextCell",
"TextData",
"TextElement",
"TextSpan",
]
+49 -35
View File
@@ -1,20 +1,34 @@
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Iterator
from collections.abc import Awaitable, Iterable
from typing import Any
from nonebot.compat import model_dump
from pydantic import BaseModel
from zhenxun.services.renderer.protocols import Renderable
from zhenxun.utils.pydantic_compat import compat_computed_field, model_dump
__all__ = ["ContainerComponent", "RenderableComponent"]
class RenderableComponent(BaseModel, Renderable):
"""所有可渲染UI组件的抽象基类。"""
"""
所有可渲染UI组件的数据模型基类。
它继承自 Pydantic 的 `BaseModel` 用于数据校验和结构化,同时实现了 `Renderable`
协议,确保其能够被 `RendererService` 正确处理。
它还提供了一些所有组件通用的样式属性,如 `inline_style`, `variant` 等。
"""
_is_standalone_template: bool = False
"""标记此组件是否为独立模板"""
inline_style: dict[str, str] | None = None
"""应用于组件根元素的内联CSS样式"""
component_css: str | None = None
"""注入到页面的额外CSS字符串"""
extra_classes: list[str] | None = None
"""应用于组件根元素的额外CSS类名列表"""
variant: str | None = None
"""组件的变体/皮肤名称"""
@property
def template_name(self) -> str:
@@ -30,6 +44,10 @@ class RenderableComponent(BaseModel, Renderable):
"""[可选] 生命周期钩子,默认无操作。"""
pass
def get_children(self) -> Iterable["RenderableComponent"]:
"""默认实现:非容器组件没有子组件。"""
return []
def get_required_scripts(self) -> list[str]:
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于assets目录)。"""
return []
@@ -40,9 +58,18 @@ class RenderableComponent(BaseModel, Renderable):
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
"""默认实现,返回模型自身的数据字典。"""
return model_dump(self)
return model_dump(
self, exclude={"inline_style", "component_css", "inline_style_str"}
)
def get_extra_css(self, theme_manager: Any) -> str | Awaitable[str]:
@compat_computed_field
def inline_style_str(self) -> str:
"""[新增] 一个辅助属性,将内联样式字典转换为CSS字符串"""
if not self.inline_style:
return ""
return "; ".join(f"{k}: {v}" for k, v in self.inline_style.items())
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
return ""
@@ -52,37 +79,24 @@ class ContainerComponent(RenderableComponent, ABC):
"""
@abstractmethod
def _get_renderable_child_items(self) -> Iterator[Any]:
def get_children(self) -> Iterable[RenderableComponent]:
"""
一个抽象方法,子类必须实现它来返回一个可迭代的对象。
迭代器中的每个项目都必须具有 'component' 和 'html_content' 属性。
一个抽象方法,子类必须实现它来返回一个可迭代的子组件。
"""
raise NotImplementedError
async def prepare(self) -> None:
"""
通用的 prepare 方法,负责预渲染所有子组件。
"""
from zhenxun.services import renderer_service
def get_required_scripts(self) -> list[str]:
"""[新增] 聚合所有子组件的脚本依赖。"""
scripts = set(super().get_required_scripts())
for child in self.get_children():
if child:
scripts.update(child.get_required_scripts())
return list(scripts)
child_items = list(self._get_renderable_child_items())
if not child_items:
return
components_to_render = [
item.component for item in child_items if item.component
]
prepare_tasks = [
comp.prepare() for comp in components_to_render if hasattr(comp, "prepare")
]
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
render_tasks = [
renderer_service.render_to_html(comp) for comp in components_to_render
]
rendered_htmls = await asyncio.gather(*render_tasks)
for item, html in zip(child_items, rendered_htmls):
item.html_content = html
def get_required_styles(self) -> list[str]:
"""[新增] 聚合所有子组件的样式依赖。"""
styles = set(super().get_required_styles())
for child in self.get_children():
if child:
styles.update(child.get_required_styles())
return list(styles)
+27
View File
@@ -0,0 +1,27 @@
from collections.abc import Iterable
from .base import ContainerComponent, RenderableComponent
class CardData(ContainerComponent):
"""通用卡片的数据模型,可以包含头部、内容和尾部"""
header: RenderableComponent | None = None
"""卡片的头部内容组件"""
content: RenderableComponent
"""卡片的主要内容组件"""
footer: RenderableComponent | None = None
"""卡片的尾部内容组件"""
@property
def template_name(self) -> str:
return "components/core/card"
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS收集器能够遍历卡片的子组件"""
if self.header:
yield self.header
if self.content:
yield self.content
if self.footer:
yield self.footer
+27
View File
@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
from .base import RenderableComponent
class DetailsItem(BaseModel):
"""描述列表中的单个项目"""
label: str = Field(..., description="项目的标签/键")
"""项目的标签/键"""
value: Any = Field(..., description="项目的值")
"""项目的值"""
class DetailsData(RenderableComponent):
"""描述列表(键值对)的数据模型"""
title: str | None = Field(None, description="列表的可选标题")
"""列表的可选标题"""
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
"""键值对项目列表"""
@property
def template_name(self) -> str:
return "components/core/details"
+27 -18
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field
@@ -11,39 +12,47 @@ class LayoutItem(BaseModel):
"""布局中的单个项目,现在持有可渲染组件的数据模型"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
html_content: str | None = None
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent):
"""布局构建器的数据模型"""
style_name: str | None = None
"""应用于布局容器的样式名称"""
layout_type: str = "column"
"""布局类型 (如 'column', 'row', 'grid')"""
children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表"
)
"""要布局的项目列表"""
options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项"
)
def get_required_scripts(self) -> list[str]:
"""[新增] 聚合所有子组件的脚本依赖。"""
scripts = set()
for item in self.children:
scripts.update(item.component.get_required_scripts())
return list(scripts)
def get_required_styles(self) -> list[str]:
"""[新增] 聚合所有子组件的样式依赖。"""
styles = set()
for item in self.children:
styles.update(item.component.get_required_styles())
return list(styles)
"""传递给模板的选项"""
@property
def template_name(self) -> str:
return f"layouts/{self.layout_type}"
return f"components/core/layouts/{self.layout_type}"
def _get_renderable_child_items(self):
yield from self.children
def get_extra_css(self, context: Any) -> str:
"""聚合所有子组件的 extra_css。"""
all_css = []
if self.component_css:
all_css.append(self.component_css)
for item in self.children:
if (
item.component
and hasattr(item.component, "component_css")
and item.component.component_css
):
all_css.append(item.component.component_css)
return "\n".join(all_css)
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.children:
yield item.component
+33
View File
@@ -0,0 +1,33 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel, Field
from .base import ContainerComponent, RenderableComponent
__all__ = ["ListData", "ListItem"]
class ListItem(BaseModel):
"""列表中的单个项目,其内容可以是任何可渲染组件。"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
class ListData(ContainerComponent):
"""通用列表的数据模型,支持有序和无序列表。"""
component_type: Literal["list"] = "list"
items: list[ListItem] = Field(default_factory=list, description="列表项目")
"""列表项目"""
ordered: bool = Field(default=False, description="是否为有序列表")
"""是否为有序列表"""
@property
def template_name(self) -> str:
return "components/core/list"
def get_children(self) -> Iterable[RenderableComponent]:
for item in self.items:
yield item.component
+69 -14
View File
@@ -1,16 +1,18 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Literal
from typing import Any, Literal
import aiofiles
from pydantic import BaseModel, Field
from zhenxun.services.log import logger
from .base import RenderableComponent
from .base import ContainerComponent, RenderableComponent
__all__ = [
"CodeElement",
"ComponentElement",
"HeadingElement",
"ImageElement",
"ListElement",
@@ -32,6 +34,7 @@ class MarkdownElement(BaseModel, ABC):
class TextElement(MarkdownElement):
type: Literal["text"] = "text"
text: str
def to_markdown(self) -> str:
@@ -39,40 +42,55 @@ class TextElement(MarkdownElement):
class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading"
text: str
level: int = Field(..., ge=1, le=6)
"""标题文本"""
level: int = Field(..., ge=1, le=6, description="标题级别 (1-6)")
"""标题级别 (1-6)"""
def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}"
class ImageElement(MarkdownElement):
type: Literal["image"] = "image"
src: str
"""图片来源 (URL或data URI)"""
alt: str = "image"
"""图片的替代文本"""
def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})"
class CodeElement(MarkdownElement):
type: Literal["code"] = "code"
code: str
"""代码字符串"""
language: str = ""
"""代码语言,用于语法高亮"""
def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```"
class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html"
html: str
"""原始HTML字符串"""
def to_markdown(self) -> str:
return self.html
class TableElement(MarkdownElement):
type: Literal["table"] = "table"
headers: list[str]
"""表格的表头列表"""
rows: list[list[str]]
"""表格的数据行列表"""
alignments: list[Literal["left", "center", "right"]] | None = None
"""每列的对齐方式"""
def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |"
@@ -94,10 +112,15 @@ class TableElement(MarkdownElement):
class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(default_factory=list)
content: list[MarkdownElement] = Field(
default_factory=list, description="容器内包含的Markdown元素列表"
)
"""容器内包含的Markdown元素列表"""
class QuoteElement(ContainerElement):
type: Literal["quote"] = "quote"
def to_markdown(self) -> str:
inner_md = "\n".join(part.to_markdown() for part in self.content)
return "\n".join([f"> {line}" for line in inner_md.split("\n")])
@@ -109,7 +132,9 @@ class ListItemElement(ContainerElement):
class ListElement(ContainerElement):
type: Literal["list"] = "list"
ordered: bool = False
"""是否为有序列表 (例如 1., 2.)"""
def to_markdown(self) -> str:
lines = []
@@ -121,19 +146,52 @@ class ListElement(ContainerElement):
return "\n".join(lines)
class MarkdownData(RenderableComponent):
class ComponentElement(MarkdownElement):
"""一个特殊的元素,用于在Markdown流中持有另一个可渲染组件。"""
type: Literal["component"] = "component"
component: RenderableComponent
"""嵌入在Markdown中的可渲染组件"""
def to_markdown(self) -> str:
return ""
class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型"""
style_name: str | None = None
markdown: str
"""Markdown内容的样式名称"""
elements: list[MarkdownElement] = Field(
default_factory=list, description="构成Markdown文档的元素列表"
)
"""构成Markdown文档的元素列表"""
width: int = 800
"""最终渲染图片的宽度"""
css_path: str | None = None
"""自定义CSS文件的绝对路径"""
@property
def template_name(self) -> str:
return "components/core/markdown"
async def get_extra_css(self, theme_manager) -> str:
def get_children(self) -> Iterable[RenderableComponent]:
"""让CSS/JS依赖收集器能够递归地找到所有嵌入的组件。"""
def find_components_recursive(
elements: list[MarkdownElement],
) -> Iterable[RenderableComponent]:
for element in elements:
if isinstance(element, ComponentElement):
yield element.component
if hasattr(element.component, "get_children"):
yield from element.component.get_children()
elif isinstance(element, ContainerElement):
yield from find_components_recursive(element.content)
yield from find_components_recursive(self.elements)
async def get_extra_css(self, context: Any) -> str:
if self.css_path:
css_file = Path(self.css_path)
if css_file.is_file():
@@ -142,14 +200,11 @@ class MarkdownData(RenderableComponent):
else:
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else:
style_name = self.style_name or "github-light"
css_path = (
theme_manager.current_theme.default_assets_dir
/ "css"
/ "markdown"
/ f"{style_name}.css"
style_name = self.style_name or "light"
css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context
)
if css_path.exists():
if css_path and css_path.exists():
async with aiofiles.open(css_path, encoding="utf-8") as f:
return await f.read()
return ""
+14 -3
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Literal
from pydantic import BaseModel
@@ -21,28 +22,38 @@ class NotebookElement(BaseModel):
"component",
]
text: str | None = None
"""元素的文本内容 (用于标题、段落、引用)"""
level: int | None = None
"""标题的级别 (1-4)"""
src: str | None = None
"""图片的来源 (URL或data URI)"""
caption: str | None = None
"""图片的说明文字"""
code: str | None = None
"""代码块的内容"""
language: str | None = None
"""代码块的语言"""
data: list[str] | None = None
"""列表项的内容列表"""
ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None
html_content: str | None = None
"""嵌入的自定义可渲染组件"""
class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型"""
style_name: str | None = None
"""Notebook的样式名称"""
elements: list[NotebookElement]
"""构成Notebook页面的元素列表"""
@property
def template_name(self) -> str:
return "components/core/notebook"
def _get_renderable_child_items(self):
def get_children(self) -> Iterable[RenderableComponent]:
for element in self.elements:
if element.type == "component" and element.component:
yield element
yield element.component
+55 -1
View File
@@ -2,11 +2,16 @@ from typing import Literal
from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent
from .text import TextSpan
__all__ = [
"BaseCell",
"ComponentCell",
"ImageCell",
"ProgressBarCell",
"RichTextCell",
"StatusBadgeCell",
"TableCell",
"TableData",
@@ -48,17 +53,66 @@ class StatusBadgeCell(BaseCell):
status_type: Literal["ok", "error", "warning", "info"] = "info"
TableCell = TextCell | ImageCell | StatusBadgeCell | str | int | float | None
class ProgressBarCell(BaseCell, ProgressBar):
"""进度条单元格,继承ProgressBar模型以复用其字段"""
type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
"""富文本单元格,支持多个带样式的文本片段"""
type: Literal["rich_text"] = "rich_text" # type: ignore
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
direction: Literal["column", "row"] = Field("column", description="片段排列方向")
"""片段排列方向"""
gap: str = Field("4px", description="片段之间的间距")
"""片段之间的间距"""
class ComponentCell(BaseCell):
"""一个通用的单元格,可以容纳任何可渲染的组件。"""
type: str = "component"
component: RenderableComponent
TableCell = (
TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
)
class TableData(RenderableComponent):
"""通用表格的数据模型"""
style_name: str | None = None
"""应用于表格容器的样式名称"""
title: str = Field(..., description="表格主标题")
"""表格主标题"""
tip: str | None = Field(None, description="表格下方的提示信息")
"""表格下方的提示信息"""
headers: list[str] = Field(default_factory=list, description="表头列表")
"""表头列表"""
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
"""数据行列表"""
column_alignments: list[Literal["left", "center", "right"]] | None = Field(
default=None, description="每列的对齐方式"
)
"""每列的对齐方式"""
column_widths: list[str | int] | None = Field(
default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])"
)
"""每列的宽度 (e.g., ['50px', 'auto', 100])"""
@property
def template_name(self) -> str:
+16 -2
View File
@@ -1,6 +1,8 @@
from pathlib import Path
from typing import Any
from pydantic import Field
from .base import RenderableComponent
__all__ = ["TemplateComponent"]
@@ -10,8 +12,11 @@ class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件"""
_is_standalone_template: bool = True
template_path: str | Path
data: dict[str, Any]
"""标记此组件为独立模板"""
template_path: str | Path = Field(..., description="指向HTML模板文件的路径")
"""指向HTML模板文件的路径"""
data: dict[str, Any] = Field(..., description="传递给模板的上下文数据字典")
"""传递给模板的上下文数据字典"""
@property
def template_name(self) -> str:
@@ -23,3 +28,12 @@ class TemplateComponent(RenderableComponent):
def get_render_data(self) -> dict[str, Any]:
"""返回传递给模板的数据"""
return self.data
def __getattr__(self, name: str) -> Any:
"""允许直接访问 `data` 字典中的属性。"""
try:
return self.data[name]
except KeyError:
raise AttributeError(
f"'{type(self).__name__}' 对象没有属性 '{name}'"
) from None
+34
View File
@@ -0,0 +1,34 @@
from typing import Literal
from pydantic import BaseModel, Field
from .base import RenderableComponent
class TextSpan(BaseModel):
"""单个富文本片段的数据模型"""
text: str
bold: bool = False
italic: bool = False
underline: bool = False
strikethrough: bool = False
code: bool = False
color: str | None = None
font_size: str | None = None
font_family: str | None = None
class TextData(RenderableComponent):
"""轻量级富文本组件的数据模型"""
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
align: Literal["left", "right", "center"] = Field(
"left", description="整体文本对齐方式"
)
"""整体文本对齐方式"""
@property
def template_name(self) -> str:
return "components/core/text"
+1 -5
View File
@@ -3,16 +3,12 @@
包含预定义的复合组件数据模型
"""
from .card import InfoCardData, InfoCardMetadataItem, InfoCardSection
from .help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_help_page import HelpCategory, HelpItem, PluginHelpPageData
from .plugin_menu import PluginMenuCategory, PluginMenuData, PluginMenuItem
__all__ = [
"HelpCategory",
"HelpItem",
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
"PluginHelpPageData",
"PluginMenuCategory",
"PluginMenuData",
-36
View File
@@ -1,36 +0,0 @@
from pydantic import BaseModel, Field
from ..core.base import RenderableComponent
__all__ = [
"InfoCardData",
"InfoCardMetadataItem",
"InfoCardSection",
]
class InfoCardMetadataItem(BaseModel):
"""信息卡片元数据项"""
label: str
value: str | int
class InfoCardSection(BaseModel):
"""信息卡片内容区块"""
title: str
content: list[str] = Field(..., description="内容段落列表")
class InfoCardData(RenderableComponent):
"""通用信息卡片的数据模型"""
style_name: str | None = None
title: str = Field(..., description="卡片主标题")
metadata: list[InfoCardMetadataItem] = Field(default_factory=list)
sections: list[InfoCardSection] = Field(default_factory=list)
@property
def template_name(self) -> str:
return "components/presets/info_card"
@@ -13,26 +13,36 @@ class HelpItem(BaseModel):
"""帮助菜单中的单个功能项"""
name: str
"""功能名称"""
description: str
"""功能描述"""
usage: str
"""功能用法说明"""
class HelpCategory(BaseModel):
"""帮助菜单中的一个功能类别"""
title: str
"""分类标题"""
icon_svg_path: str
"""分类图标的SVG路径数据"""
items: list[HelpItem]
"""该分类下的功能项列表"""
class PluginHelpPageData(RenderableComponent):
"""通用插件帮助页面的数据模型"""
style_name: str | None = None
"""页面样式名称"""
bot_nickname: str
"""机器人昵称"""
page_title: str
"""页面主标题"""
categories: list[HelpCategory]
"""帮助分类列表"""
@property
def template_name(self) -> str:
return "pages/core/help_page"
return "pages/core/plugin_help_page"
+16 -2
View File
@@ -13,29 +13,43 @@ class PluginMenuItem(BaseModel):
"""插件菜单中的单个插件项"""
id: str
"""插件的唯一ID"""
name: str
"""插件名称"""
status: bool
"""插件在当前群组的开关状态"""
has_superuser_help: bool
commands: list[str] = Field(default_factory=list)
"""插件是否有超级用户专属帮助"""
commands: list[str] = Field(default_factory=list, description="插件的主要命令列表")
"""插件的主要命令列表"""
class PluginMenuCategory(BaseModel):
"""插件菜单中的一个分类"""
name: str
items: list[PluginMenuItem]
"""插件分类名称"""
items: list[PluginMenuItem] = Field(..., description="该分类下的插件项列表")
"""该分类下的插件项列表"""
class PluginMenuData(RenderableComponent):
"""通用插件帮助菜单的数据模型"""
style_name: str | None = None
"""页面样式名称"""
bot_name: str
"""机器人名称"""
bot_avatar_url: str
"""机器人头像URL"""
is_detail: bool
"""是否为详细菜单模式"""
plugin_count: int
"""总插件数量"""
active_count: int
"""已启用插件数量"""
categories: list[PluginMenuCategory]
"""插件分类列表"""
@property
def template_name(self) -> str:
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from typing import Any, Literal
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_htmlrender import get_browser
from nonebot_plugin_htmlrender.browser import get_browser
from playwright.async_api import Page
from zhenxun.utils.message import MessageUtils

Some files were not shown because too many files have changed in this diff Show More